mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
7 Commits
main
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f444947056 | ||
|
|
bf616f8422 | ||
|
|
4db6bf272a | ||
|
|
aa43c3f732 | ||
|
|
408597e12b | ||
|
|
932f8fd654 | ||
|
|
b4c2294e67 |
35
.claude/rules/emcn-components.md
Normal file
35
.claude/rules/emcn-components.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/components/emcn/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# EMCN Components
|
||||||
|
|
||||||
|
Import from `@/components/emcn`, never from subpaths (except CSS files).
|
||||||
|
|
||||||
|
## CVA vs Direct Styles
|
||||||
|
|
||||||
|
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const buttonVariants = cva('base-classes', {
|
||||||
|
variants: { variant: { default: '...', primary: '...' } }
|
||||||
|
})
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use direct className when:** Single consistent style, no variations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Label({ className, ...props }) {
|
||||||
|
return <Primitive className={cn('style-classes', className)} {...props} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Use Radix UI primitives for accessibility
|
||||||
|
- Export component and variants (if using CVA)
|
||||||
|
- TSDoc with usage examples
|
||||||
|
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
||||||
|
- `transition-colors` for hover states
|
||||||
13
.claude/rules/global.md
Normal file
13
.claude/rules/global.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Global Standards
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
Never update global styles. Keep all styling local to components.
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||||
56
.claude/rules/sim-architecture.md
Normal file
56
.claude/rules/sim-architecture.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sim App Architecture
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||||
|
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||||
|
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||||
|
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||||
|
|
||||||
|
## Root-Level Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/sim/
|
||||||
|
├── app/ # Next.js app router (pages, API routes)
|
||||||
|
├── blocks/ # Block definitions and registry
|
||||||
|
├── components/ # Shared UI (emcn/, ui/)
|
||||||
|
├── executor/ # Workflow execution engine
|
||||||
|
├── hooks/ # Shared hooks (queries/, selectors/)
|
||||||
|
├── lib/ # App-wide utilities
|
||||||
|
├── providers/ # LLM provider integrations
|
||||||
|
├── stores/ # Zustand stores
|
||||||
|
├── tools/ # Tool definitions
|
||||||
|
└── triggers/ # Trigger definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Organization
|
||||||
|
|
||||||
|
Features live under `app/workspace/[workspaceId]/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/
|
||||||
|
├── components/ # Feature components
|
||||||
|
├── hooks/ # Feature-scoped hooks
|
||||||
|
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||||
|
├── feature.tsx # Main component
|
||||||
|
└── page.tsx # Next.js page entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
- **Components**: PascalCase (`WorkflowList`)
|
||||||
|
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||||
|
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||||
|
- **Stores**: `stores/feature/store.ts`
|
||||||
|
- **Constants**: SCREAMING_SNAKE_CASE
|
||||||
|
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||||
|
|
||||||
|
## Utils Rules
|
||||||
|
|
||||||
|
- **Never create `utils.ts` for single consumer** - inline it
|
||||||
|
- **Create `utils.ts` when** 2+ files need the same helper
|
||||||
|
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||||
|
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||||
48
.claude/rules/sim-components.md
Normal file
48
.claude/rules/sim-components.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component Patterns
|
||||||
|
|
||||||
|
## Structure Order
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client' // Only if using hooks
|
||||||
|
|
||||||
|
// Imports (external → internal)
|
||||||
|
// Constants at module level
|
||||||
|
const CONFIG = { SPACING: 8 } as const
|
||||||
|
|
||||||
|
// Props interface
|
||||||
|
interface ComponentProps {
|
||||||
|
requiredProp: string
|
||||||
|
optionalProp?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
||||||
|
// a. Refs
|
||||||
|
// b. External hooks (useParams, useRouter)
|
||||||
|
// c. Store hooks
|
||||||
|
// d. Custom hooks
|
||||||
|
// e. Local state
|
||||||
|
// f. useMemo
|
||||||
|
// g. useCallback
|
||||||
|
// h. useEffect
|
||||||
|
// i. Return JSX
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. `'use client'` only when using React hooks
|
||||||
|
2. Always define props interface
|
||||||
|
3. Extract constants with `as const`
|
||||||
|
4. Semantic HTML (`aside`, `nav`, `article`)
|
||||||
|
5. Optional chain callbacks: `onAction?.(id)`
|
||||||
|
|
||||||
|
## Component Extraction
|
||||||
|
|
||||||
|
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
|
||||||
|
|
||||||
|
**Keep inline when:** < 10 lines, single use, purely presentational
|
||||||
55
.claude/rules/sim-hooks.md
Normal file
55
.claude/rules/sim-hooks.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/use-*.ts"
|
||||||
|
- "apps/sim/**/hooks/**/*.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hook Patterns
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UseFeatureProps {
|
||||||
|
id: string
|
||||||
|
onSuccess?: (result: Result) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
||||||
|
// 1. Refs for stable dependencies
|
||||||
|
const idRef = useRef(id)
|
||||||
|
const onSuccessRef = useRef(onSuccess)
|
||||||
|
|
||||||
|
// 2. State
|
||||||
|
const [data, setData] = useState<Data | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// 3. Sync refs
|
||||||
|
useEffect(() => {
|
||||||
|
idRef.current = id
|
||||||
|
onSuccessRef.current = onSuccess
|
||||||
|
}, [id, onSuccess])
|
||||||
|
|
||||||
|
// 4. Operations (useCallback with empty deps when using refs)
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
||||||
|
setData(result)
|
||||||
|
onSuccessRef.current?.(result)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { data, isLoading, fetchData }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Single responsibility per hook
|
||||||
|
2. Props interface required
|
||||||
|
3. Refs for stable callback dependencies
|
||||||
|
4. Wrap returned functions in useCallback
|
||||||
|
5. Always try/catch async operations
|
||||||
|
6. Track loading/error states
|
||||||
62
.claude/rules/sim-imports.md
Normal file
62
.claude/rules/sim-imports.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/*.ts"
|
||||||
|
- "apps/sim/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Import Patterns
|
||||||
|
|
||||||
|
## Absolute Imports
|
||||||
|
|
||||||
|
**Always use absolute imports.** Never use relative imports.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✓ Good
|
||||||
|
import { useWorkflowStore } from '@/stores/workflows/store'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// ✗ Bad
|
||||||
|
import { useWorkflowStore } from '../../../stores/workflows/store'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Barrel Exports
|
||||||
|
|
||||||
|
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✓ Good
|
||||||
|
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||||
|
|
||||||
|
// ✗ Bad
|
||||||
|
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
||||||
|
```
|
||||||
|
|
||||||
|
## No Re-exports
|
||||||
|
|
||||||
|
Do not re-export from non-barrel files. Import directly from the source.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✓ Good - import from where it's declared
|
||||||
|
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||||
|
|
||||||
|
// ✗ Bad - re-exporting in utils.ts then importing from there
|
||||||
|
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Order
|
||||||
|
|
||||||
|
1. React/core libraries
|
||||||
|
2. External libraries
|
||||||
|
3. UI components (`@/components/emcn`, `@/components/ui`)
|
||||||
|
4. Utilities (`@/lib/...`)
|
||||||
|
5. Stores (`@/stores/...`)
|
||||||
|
6. Feature imports
|
||||||
|
7. CSS imports
|
||||||
|
|
||||||
|
## Type Imports
|
||||||
|
|
||||||
|
Use `type` keyword for type-only imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { WorkflowLog } from '@/stores/logs/types'
|
||||||
|
```
|
||||||
209
.claude/rules/sim-integrations.md
Normal file
209
.claude/rules/sim-integrations.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/tools/**"
|
||||||
|
- "apps/sim/blocks/**"
|
||||||
|
- "apps/sim/triggers/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Adding Integrations
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Adding a new integration typically requires:
|
||||||
|
1. **Tools** - API operations (`tools/{service}/`)
|
||||||
|
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
|
||||||
|
3. **Icon** - SVG icon (`components/icons.tsx`)
|
||||||
|
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
|
||||||
|
|
||||||
|
Always look up the service's API docs first.
|
||||||
|
|
||||||
|
## 1. Tools (`tools/{service}/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/{service}/
|
||||||
|
├── index.ts # Export all tools
|
||||||
|
├── types.ts # Params/response types
|
||||||
|
├── {action}.ts # Individual tool (e.g., send_message.ts)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool file structure:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tools/{service}/{action}.ts
|
||||||
|
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
|
||||||
|
import type { ToolConfig } from '@/tools/types'
|
||||||
|
|
||||||
|
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
|
||||||
|
id: '{service}_{action}',
|
||||||
|
name: '{Service} {Action}',
|
||||||
|
description: 'What this tool does',
|
||||||
|
version: '1.0.0',
|
||||||
|
oauth: { required: true, provider: '{service}' }, // if OAuth
|
||||||
|
params: { /* param definitions */ },
|
||||||
|
request: {
|
||||||
|
url: '/api/tools/{service}/{action}',
|
||||||
|
method: 'POST',
|
||||||
|
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||||
|
body: (params) => ({ ...params }),
|
||||||
|
},
|
||||||
|
transformResponse: async (response) => {
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) throw new Error(data.error)
|
||||||
|
return { success: true, output: data.output }
|
||||||
|
},
|
||||||
|
outputs: { /* output definitions */ },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register in `tools/registry.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { {service}{Action}Tool } from '@/tools/{service}'
|
||||||
|
// Add to registry object
|
||||||
|
{service}_{action}: {service}{Action}Tool,
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Block (`blocks/blocks/{service}.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { {Service}Icon } from '@/components/icons'
|
||||||
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
|
import type { {Service}Response } from '@/tools/{service}/types'
|
||||||
|
|
||||||
|
export const {Service}Block: BlockConfig<{Service}Response> = {
|
||||||
|
type: '{service}',
|
||||||
|
name: '{Service}',
|
||||||
|
description: 'Short description',
|
||||||
|
longDescription: 'Detailed description',
|
||||||
|
category: 'tools',
|
||||||
|
bgColor: '#hexcolor',
|
||||||
|
icon: {Service}Icon,
|
||||||
|
subBlocks: [ /* see SubBlock Properties below */ ],
|
||||||
|
tools: {
|
||||||
|
access: ['{service}_{action}', ...],
|
||||||
|
config: {
|
||||||
|
tool: (params) => `{service}_${params.operation}`,
|
||||||
|
params: (params) => ({ ...params }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inputs: { /* input definitions */ },
|
||||||
|
outputs: { /* output definitions */ },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubBlock Properties
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'fieldName', // Unique identifier
|
||||||
|
title: 'Field Label', // UI label
|
||||||
|
type: 'short-input', // See SubBlock Types below
|
||||||
|
placeholder: 'Hint text',
|
||||||
|
required: true, // See Required below
|
||||||
|
condition: { ... }, // See Condition below
|
||||||
|
dependsOn: ['otherField'], // See DependsOn below
|
||||||
|
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
|
||||||
|
|
||||||
|
### `condition` - Show/hide based on another field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Show when operation === 'send'
|
||||||
|
condition: { field: 'operation', value: 'send' }
|
||||||
|
|
||||||
|
// Show when operation is 'send' OR 'read'
|
||||||
|
condition: { field: 'operation', value: ['send', 'read'] }
|
||||||
|
|
||||||
|
// Show when operation !== 'send'
|
||||||
|
condition: { field: 'operation', value: 'send', not: true }
|
||||||
|
|
||||||
|
// Complex: NOT in list AND another condition
|
||||||
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['list_channels', 'list_users'],
|
||||||
|
not: true,
|
||||||
|
and: { field: 'destinationType', value: 'dm', not: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `required` - Field validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Always required
|
||||||
|
required: true
|
||||||
|
|
||||||
|
// Conditionally required (same syntax as condition)
|
||||||
|
required: { field: 'operation', value: 'send' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dependsOn` - Clear field when dependencies change
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Clear when credential changes
|
||||||
|
dependsOn: ['credential']
|
||||||
|
|
||||||
|
// Clear when authMethod changes AND (credential OR botToken) changes
|
||||||
|
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mode` - When to show field
|
||||||
|
|
||||||
|
- `'basic'` - Only in basic mode (default UI)
|
||||||
|
- `'advanced'` - Only in advanced mode (manual input)
|
||||||
|
- `'both'` - Show in both modes (default)
|
||||||
|
- `'trigger'` - Only when block is used as trigger
|
||||||
|
|
||||||
|
**Register in `blocks/registry.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { {Service}Block } from '@/blocks/blocks/{service}'
|
||||||
|
// Add to registry object (alphabetically)
|
||||||
|
{service}: {Service}Block,
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Icon (`components/icons.tsx`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* SVG path from service's brand assets */}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Trigger (`triggers/{service}/`) - Optional
|
||||||
|
|
||||||
|
```
|
||||||
|
triggers/{service}/
|
||||||
|
├── index.ts # Export all triggers
|
||||||
|
├── webhook.ts # Webhook handler
|
||||||
|
├── utils.ts # Shared utilities
|
||||||
|
└── {event}.ts # Specific event handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register in `triggers/registry.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { {service}WebhookTrigger } from '@/triggers/{service}'
|
||||||
|
// Add to TRIGGER_REGISTRY
|
||||||
|
{service}_webhook: {service}WebhookTrigger,
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Look up API docs for the service
|
||||||
|
- [ ] Create `tools/{service}/types.ts` with proper types
|
||||||
|
- [ ] Create tool files for each operation
|
||||||
|
- [ ] Create `tools/{service}/index.ts` barrel export
|
||||||
|
- [ ] Register tools in `tools/registry.ts`
|
||||||
|
- [ ] Add icon to `components/icons.tsx`
|
||||||
|
- [ ] Create block in `blocks/blocks/{service}.ts`
|
||||||
|
- [ ] Register block in `blocks/registry.ts`
|
||||||
|
- [ ] (Optional) Create triggers in `triggers/{service}/`
|
||||||
|
- [ ] (Optional) Register triggers in `triggers/registry.ts`
|
||||||
66
.claude/rules/sim-queries.md
Normal file
66
.claude/rules/sim-queries.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/hooks/queries/**/*.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# React Query Patterns
|
||||||
|
|
||||||
|
All React Query hooks live in `hooks/queries/`.
|
||||||
|
|
||||||
|
## Query Key Factory
|
||||||
|
|
||||||
|
Every query file defines a keys factory:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const entityKeys = {
|
||||||
|
all: ['entity'] as const,
|
||||||
|
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||||
|
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Query keys factory
|
||||||
|
// 2. Types (if needed)
|
||||||
|
// 3. Private fetch functions
|
||||||
|
// 4. Exported hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: entityKeys.list(workspaceId),
|
||||||
|
queryFn: () => fetchEntities(workspaceId as string),
|
||||||
|
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mutation Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useCreateEntity() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (variables) => { /* fetch POST */ },
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimistic Updates
|
||||||
|
|
||||||
|
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- **Keys**: `entityKeys`
|
||||||
|
- **Query hooks**: `useEntity`, `useEntityList`
|
||||||
|
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
||||||
|
- **Fetch functions**: `fetchEntity` (private)
|
||||||
71
.claude/rules/sim-stores.md
Normal file
71
.claude/rules/sim-stores.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/store.ts"
|
||||||
|
- "apps/sim/**/stores/**/*.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zustand Store Patterns
|
||||||
|
|
||||||
|
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
||||||
|
|
||||||
|
## Basic Store
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { devtools } from 'zustand/middleware'
|
||||||
|
import type { FeatureState } from '@/stores/feature/types'
|
||||||
|
|
||||||
|
const initialState = { items: [] as Item[], activeId: null as string | null }
|
||||||
|
|
||||||
|
export const useFeatureStore = create<FeatureState>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
setItems: (items) => set({ items }),
|
||||||
|
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}),
|
||||||
|
{ name: 'feature-store' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persisted Store
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export const useFeatureStore = create<FeatureState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
width: 300,
|
||||||
|
setWidth: (width) => set({ width }),
|
||||||
|
_hasHydrated: false,
|
||||||
|
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'feature-state',
|
||||||
|
partialize: (state) => ({ width: state.width }),
|
||||||
|
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Use `devtools` middleware (named stores)
|
||||||
|
2. Use `persist` only when data should survive reload
|
||||||
|
3. `partialize` to persist only necessary state
|
||||||
|
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
||||||
|
5. Immutable updates only
|
||||||
|
6. `set((state) => ...)` when depending on previous state
|
||||||
|
7. Provide `reset()` action
|
||||||
|
|
||||||
|
## Outside React
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const items = useFeatureStore.getState().items
|
||||||
|
useFeatureStore.setState({ items: newItems })
|
||||||
|
```
|
||||||
41
.claude/rules/sim-styling.md
Normal file
41
.claude/rules/sim-styling.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/*.tsx"
|
||||||
|
- "apps/sim/**/*.css"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Styling Rules
|
||||||
|
|
||||||
|
## Tailwind
|
||||||
|
|
||||||
|
1. **No inline styles** - Use Tailwind classes
|
||||||
|
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
||||||
|
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
||||||
|
4. **Transitions** - `transition-colors` for interactive states
|
||||||
|
|
||||||
|
## Conditional Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
'base-classes',
|
||||||
|
isActive && 'active-classes',
|
||||||
|
disabled ? 'opacity-60' : 'hover:bg-accent'
|
||||||
|
)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Variables
|
||||||
|
|
||||||
|
For dynamic values (widths, heights) synced with stores:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In store
|
||||||
|
setWidth: (width) => {
|
||||||
|
set({ width })
|
||||||
|
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In component
|
||||||
|
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||||
|
```
|
||||||
58
.claude/rules/sim-testing.md
Normal file
58
.claude/rules/sim-testing.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/*.test.ts"
|
||||||
|
- "apps/sim/**/*.test.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
import { databaseMock, loggerMock } from '@sim/testing'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
|
import { myFunction } from '@/lib/feature'
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
it.concurrent('isolated tests run in parallel', () => { ... })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## @sim/testing Package
|
||||||
|
|
||||||
|
Always prefer over local mocks.
|
||||||
|
|
||||||
|
| Category | Utilities |
|
||||||
|
|----------|-----------|
|
||||||
|
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
|
||||||
|
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
|
||||||
|
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
|
||||||
|
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. `@vitest-environment node` directive at file top
|
||||||
|
2. `vi.mock()` calls before importing mocked modules
|
||||||
|
3. `@sim/testing` utilities over local mocks
|
||||||
|
4. `it.concurrent` for isolated tests (no shared mutable state)
|
||||||
|
5. `beforeEach(() => vi.clearAllMocks())` to reset state
|
||||||
|
|
||||||
|
## Hoisted Mocks
|
||||||
|
|
||||||
|
For mutable mock references:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mockFn = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
|
||||||
|
mockFn.mockResolvedValue({ data: 'test' })
|
||||||
|
```
|
||||||
21
.claude/rules/sim-typescript.md
Normal file
21
.claude/rules/sim-typescript.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "apps/sim/**/*.ts"
|
||||||
|
- "apps/sim/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# TypeScript Rules
|
||||||
|
|
||||||
|
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||||
|
2. **Props interface** - Always define for components
|
||||||
|
3. **Const assertions** - `as const` for constant objects/arrays
|
||||||
|
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||||
|
5. **Type imports** - `import type { X }` for type-only imports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✗ Bad
|
||||||
|
const handleClick = (e: any) => {}
|
||||||
|
|
||||||
|
// ✓ Good
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||||
|
```
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { useBrandConfig } from '@/lib/branding/branding'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||||
|
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||||
|
import { InviteLayout } from '@/app/invite/components'
|
||||||
|
|
||||||
interface UnsubscribeData {
|
interface UnsubscribeData {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [processing, setProcessing] = useState(false)
|
const [processing, setProcessing] = useState(false)
|
||||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||||
const brand = useBrandConfig()
|
|
||||||
|
|
||||||
const email = searchParams.get('email')
|
const email = searchParams.get('email')
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
|
|||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to unsubscribe')
|
setError(result.error || 'Failed to unsubscribe')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
setError('Failed to process unsubscribe request')
|
setError('Failed to process unsubscribe request')
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false)
|
setProcessing(false)
|
||||||
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardContent className='flex items-center justify-center p-8'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
Loading
|
||||||
</CardContent>
|
</h1>
|
||||||
</Card>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
</div>
|
Validating your unsubscribe link...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||||
|
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||||
|
</div>
|
||||||
|
<SupportFooter position='absolute' />
|
||||||
|
</InviteLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
Invalid Unsubscribe Link
|
||||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
This unsubscribe link is invalid or has expired
|
{error}
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className='space-y-4'>
|
|
||||||
<div className='rounded-lg border bg-red-50 p-4'>
|
|
||||||
<p className='text-red-800 text-sm'>
|
|
||||||
<strong>Error:</strong> {error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
</div>
|
||||||
<li>The link is missing required parameters</li>
|
|
||||||
<li>The link has expired or been used already</li>
|
|
||||||
<li>The link was copied incorrectly</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 flex flex-col gap-3'>
|
<SupportFooter position='absolute' />
|
||||||
<Button
|
</InviteLayout>
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
|
||||||
>
|
|
||||||
Contact Support
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-4 text-center'>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Need immediate help? Email us at{' '}
|
|
||||||
<a
|
|
||||||
href={`mailto:${brand.supportEmail}`}
|
|
||||||
className='text-muted-foreground hover:underline'
|
|
||||||
>
|
|
||||||
{brand.supportEmail}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.isTransactional) {
|
if (data?.isTransactional) {
|
||||||
return (
|
return (
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
Important Account Emails
|
||||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
This email contains important information about your account
|
Transactional emails like password resets, account confirmations, and security alerts
|
||||||
</CardDescription>
|
cannot be unsubscribed from as they contain essential information for your account.
|
||||||
</CardHeader>
|
</p>
|
||||||
<CardContent className='space-y-4'>
|
</div>
|
||||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
|
||||||
<p className='text-blue-800 text-sm'>
|
|
||||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
|
||||||
and security alerts cannot be unsubscribed from as they contain essential
|
|
||||||
information for your account security and functionality.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
<p className='text-foreground text-sm'>
|
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||||
If you no longer wish to receive these emails, you can:
|
</div>
|
||||||
</p>
|
|
||||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
|
||||||
<li>Close your account entirely</li>
|
|
||||||
<li>Contact our support team for assistance</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 flex flex-col gap-3'>
|
<SupportFooter position='absolute' />
|
||||||
<Button
|
</InviteLayout>
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
|
||||||
>
|
|
||||||
Contact Support
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unsubscribed) {
|
if (unsubscribed) {
|
||||||
return (
|
return (
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
Successfully Unsubscribed
|
||||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||||
hours.
|
hours.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className='text-center'>
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
If you change your mind, you can always update your email preferences in your account
|
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||||
settings or contact us at{' '}
|
</div>
|
||||||
<a
|
|
||||||
href={`mailto:${brand.supportEmail}`}
|
<SupportFooter position='absolute' />
|
||||||
className='text-muted-foreground hover:underline'
|
</InviteLayout>
|
||||||
>
|
|
||||||
{brand.supportEmail}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
Email Preferences
|
||||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
We understand email preferences are personal. Choose which emails you'd like to
|
Choose which emails you'd like to stop receiving.
|
||||||
stop receiving from Sim.
|
</p>
|
||||||
</CardDescription>
|
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
{data?.email}
|
||||||
<p className='text-muted-foreground text-xs'>
|
</p>
|
||||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='space-y-4'>
|
|
||||||
<div className='space-y-3'>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleUnsubscribe('all')}
|
|
||||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
|
||||||
variant='destructive'
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
{data?.currentPreferences.unsubscribeAll ? (
|
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
|
||||||
) : null}
|
|
||||||
{processing
|
|
||||||
? 'Unsubscribing...'
|
|
||||||
: data?.currentPreferences.unsubscribeAll
|
|
||||||
? 'Unsubscribed from All Emails'
|
|
||||||
: 'Unsubscribe from All Marketing Emails'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className='text-center text-muted-foreground text-sm'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
or choose specific types:
|
<BrandedButton
|
||||||
</div>
|
onClick={() => handleUnsubscribe('all')}
|
||||||
|
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||||
|
loading={processing}
|
||||||
|
loadingText='Unsubscribing'
|
||||||
|
>
|
||||||
|
{isAlreadyUnsubscribedFromAll
|
||||||
|
? 'Unsubscribed from All Emails'
|
||||||
|
: 'Unsubscribe from All Marketing Emails'}
|
||||||
|
</BrandedButton>
|
||||||
|
|
||||||
<Button
|
<div className='py-2 text-center'>
|
||||||
onClick={() => handleUnsubscribe('marketing')}
|
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||||
disabled={
|
or choose specific types
|
||||||
processing ||
|
</span>
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
</div>
|
||||||
data?.currentPreferences.unsubscribeMarketing
|
|
||||||
}
|
|
||||||
variant='outline'
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
|
||||||
) : null}
|
|
||||||
{data?.currentPreferences.unsubscribeMarketing
|
|
||||||
? 'Unsubscribed from Marketing'
|
|
||||||
: 'Unsubscribe from Marketing Emails'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
onClick={() => handleUnsubscribe('updates')}
|
onClick={() => handleUnsubscribe('marketing')}
|
||||||
disabled={
|
disabled={
|
||||||
processing ||
|
processing ||
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
isAlreadyUnsubscribedFromAll ||
|
||||||
data?.currentPreferences.unsubscribeUpdates
|
data?.currentPreferences.unsubscribeMarketing
|
||||||
}
|
}
|
||||||
variant='outline'
|
>
|
||||||
className='w-full'
|
{data?.currentPreferences.unsubscribeMarketing
|
||||||
>
|
? 'Unsubscribed from Marketing'
|
||||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
: 'Unsubscribe from Marketing Emails'}
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
</BrandedButton>
|
||||||
) : null}
|
|
||||||
{data?.currentPreferences.unsubscribeUpdates
|
|
||||||
? 'Unsubscribed from Updates'
|
|
||||||
: 'Unsubscribe from Product Updates'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
onClick={() => handleUnsubscribe('notifications')}
|
onClick={() => handleUnsubscribe('updates')}
|
||||||
disabled={
|
disabled={
|
||||||
processing ||
|
processing ||
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
isAlreadyUnsubscribedFromAll ||
|
||||||
data?.currentPreferences.unsubscribeNotifications
|
data?.currentPreferences.unsubscribeUpdates
|
||||||
}
|
}
|
||||||
variant='outline'
|
>
|
||||||
className='w-full'
|
{data?.currentPreferences.unsubscribeUpdates
|
||||||
>
|
? 'Unsubscribed from Updates'
|
||||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
: 'Unsubscribe from Product Updates'}
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
</BrandedButton>
|
||||||
) : null}
|
|
||||||
{data?.currentPreferences.unsubscribeNotifications
|
|
||||||
? 'Unsubscribed from Notifications'
|
|
||||||
: 'Unsubscribe from Notifications'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 space-y-3'>
|
<BrandedButton
|
||||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
onClick={() => handleUnsubscribe('notifications')}
|
||||||
<p className='text-center text-muted-foreground text-xs'>
|
disabled={
|
||||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
processing ||
|
||||||
password resets and security alerts.
|
isAlreadyUnsubscribedFromAll ||
|
||||||
</p>
|
data?.currentPreferences.unsubscribeNotifications
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
{data?.currentPreferences.unsubscribeNotifications
|
||||||
|
? 'Unsubscribed from Notifications'
|
||||||
|
: 'Unsubscribe from Notifications'}
|
||||||
|
</BrandedButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className='text-center text-muted-foreground text-xs'>
|
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||||
Questions? Contact us at{' '}
|
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||||
<a
|
You'll continue receiving important account emails like password resets and security
|
||||||
href={`mailto:${brand.supportEmail}`}
|
alerts.
|
||||||
className='text-muted-foreground hover:underline'
|
</p>
|
||||||
>
|
</div>
|
||||||
{brand.supportEmail}
|
|
||||||
</a>
|
<SupportFooter position='absolute' />
|
||||||
</p>
|
</InviteLayout>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardContent className='flex items-center justify-center p-8'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
Loading
|
||||||
</CardContent>
|
</h1>
|
||||||
</Card>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
</div>
|
Validating your unsubscribe link...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||||
|
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||||
|
</div>
|
||||||
|
<SupportFooter position='absolute' />
|
||||||
|
</InviteLayout>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UnsubscribeContent />
|
<UnsubscribeContent />
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { PopoverSection } from '@/components/emcn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loading component for chat history dropdown
|
||||||
|
* Displays placeholder content while chats are being loaded
|
||||||
|
*/
|
||||||
|
export function ChatHistorySkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopoverSection>
|
||||||
|
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
||||||
|
</PopoverSection>
|
||||||
|
<div className='flex flex-col gap-0.5'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './chat-history-skeleton'
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button } from '@/components/emcn'
|
||||||
|
|
||||||
|
interface CheckpointDiscardModalProps {
|
||||||
|
isProcessingDiscard: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onRevert: () => void
|
||||||
|
onContinue: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline confirmation modal for discarding checkpoints during message editing
|
||||||
|
* Shows options to cancel, revert to checkpoint, or continue without reverting
|
||||||
|
*/
|
||||||
|
export function CheckpointDiscardModal({
|
||||||
|
isProcessingDiscard,
|
||||||
|
onCancel,
|
||||||
|
onRevert,
|
||||||
|
onContinue,
|
||||||
|
}: CheckpointDiscardModalProps) {
|
||||||
|
return (
|
||||||
|
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||||
|
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||||
|
Continue from a previous message?
|
||||||
|
</p>
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
variant='active'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onRevert}
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onContinue}
|
||||||
|
variant='tertiary'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './checkpoint-discard-modal'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './file-display'
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
export * from './checkpoint-discard-modal'
|
||||||
export * from './file-display'
|
export * from './file-display'
|
||||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
export { CopilotMarkdownRenderer } from './markdown-renderer'
|
||||||
|
export * from './restore-checkpoint-modal'
|
||||||
export * from './smooth-streaming'
|
export * from './smooth-streaming'
|
||||||
export * from './thinking-block'
|
export * from './thinking-block'
|
||||||
export * from './usage-limit-actions'
|
export * from './usage-limit-actions'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './restore-checkpoint-modal'
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button } from '@/components/emcn'
|
||||||
|
|
||||||
|
interface RestoreCheckpointModalProps {
|
||||||
|
isReverting: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline confirmation modal for restoring a checkpoint
|
||||||
|
* Warns user that the action cannot be undone
|
||||||
|
*/
|
||||||
|
export function RestoreCheckpointModal({
|
||||||
|
isReverting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: RestoreCheckpointModalProps) {
|
||||||
|
return (
|
||||||
|
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||||
|
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||||
|
Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '}
|
||||||
|
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||||
|
</p>
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
variant='active'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isReverting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isReverting}
|
||||||
|
>
|
||||||
|
{isReverting ? 'Reverting...' : 'Revert'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './smooth-streaming'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react'
|
import { memo, useEffect, useRef, useState } from 'react'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character animation delay in milliseconds
|
* Character animation delay in milliseconds
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './thinking-block'
|
||||||
@@ -3,15 +3,20 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronUp } from 'lucide-react'
|
import { ChevronUp } from 'lucide-react'
|
||||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes thinking tags (raw or escaped) from streamed content.
|
* Removes thinking tags (raw or escaped) from streamed content.
|
||||||
|
* Also strips special tags (options, plan) that may have been accidentally included.
|
||||||
*/
|
*/
|
||||||
function stripThinkingTags(text: string): string {
|
function stripThinkingTags(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||||
|
.replace(/<options>[\s\S]*?<\/options>/gi, '') // Strip complete options tags
|
||||||
|
.replace(/<options>[\s\S]*$/gi, '') // Strip incomplete/streaming options tags
|
||||||
|
.replace(/<plan>[\s\S]*?<\/plan>/gi, '') // Strip complete plan tags
|
||||||
|
.replace(/<plan>[\s\S]*$/gi, '') // Strip incomplete/streaming plan tags
|
||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './usage-limit-actions'
|
||||||
@@ -9,18 +9,22 @@ import {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||||
import {
|
import {
|
||||||
|
CheckpointDiscardModal,
|
||||||
FileAttachmentDisplay,
|
FileAttachmentDisplay,
|
||||||
|
RestoreCheckpointModal,
|
||||||
SmoothStreamingText,
|
SmoothStreamingText,
|
||||||
StreamingIndicator,
|
StreamingIndicator,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
UsageLimitActions,
|
UsageLimitActions,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
||||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
import {
|
import {
|
||||||
useCheckpointManagement,
|
useCheckpointManagement,
|
||||||
|
useMessageContentAnalysis,
|
||||||
useMessageEditing,
|
useMessageEditing,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||||
|
import { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
@@ -179,6 +183,32 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return message.content ? parseSpecialTags(message.content) : null
|
return message.content ? parseSpecialTags(message.content) : null
|
||||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||||
|
|
||||||
|
// Detect previously selected option by checking if the next user message matches an option
|
||||||
|
const selectedOptionKey = useMemo(() => {
|
||||||
|
if (!parsedTags?.options || isStreaming) return null
|
||||||
|
|
||||||
|
// Find the index of this message in the messages array
|
||||||
|
const currentIndex = messages.findIndex((m) => m.id === message.id)
|
||||||
|
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
|
||||||
|
|
||||||
|
// Get the next message
|
||||||
|
const nextMessage = messages[currentIndex + 1]
|
||||||
|
if (!nextMessage || nextMessage.role !== 'user') return null
|
||||||
|
|
||||||
|
const nextContent = nextMessage.content?.trim()
|
||||||
|
if (!nextContent) return null
|
||||||
|
|
||||||
|
// Check if the next user message content matches any option title
|
||||||
|
for (const [key, option] of Object.entries(parsedTags.options)) {
|
||||||
|
const optionTitle = typeof option === 'string' ? option : option.title
|
||||||
|
if (nextContent === optionTitle) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [parsedTags?.options, messages, message.id, isStreaming])
|
||||||
|
|
||||||
// Get sendMessage from store for continuation actions
|
// Get sendMessage from store for continuation actions
|
||||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||||
|
|
||||||
@@ -191,6 +221,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
[sendMessage]
|
[sendMessage]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Analyze message content for visibility (used for assistant messages)
|
||||||
|
const { hasVisibleContent } = useMessageContentAnalysis({ message })
|
||||||
|
|
||||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||||
// No entrance animations to prevent layout shift
|
// No entrance animations to prevent layout shift
|
||||||
const memoizedContentBlocks = useMemo(() => {
|
const memoizedContentBlocks = useMemo(() => {
|
||||||
@@ -290,40 +323,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
|
|
||||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||||
{showCheckpointDiscardModal && (
|
{showCheckpointDiscardModal && (
|
||||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
<CheckpointDiscardModal
|
||||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
isProcessingDiscard={isProcessingDiscard}
|
||||||
Continue from a previous message?
|
onCancel={handleCancelCheckpointDiscard}
|
||||||
</p>
|
onRevert={handleContinueAndRevert}
|
||||||
<div className='flex gap-[8px]'>
|
onContinue={handleContinueWithoutRevert}
|
||||||
<Button
|
/>
|
||||||
onClick={handleCancelCheckpointDiscard}
|
|
||||||
variant='active'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessingDiscard}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleContinueAndRevert}
|
|
||||||
variant='destructive'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessingDiscard}
|
|
||||||
>
|
|
||||||
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleContinueWithoutRevert}
|
|
||||||
variant='tertiary'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessingDiscard}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -348,46 +353,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
ref={messageContentRef}
|
ref={messageContentRef}
|
||||||
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
||||||
>
|
>
|
||||||
{(() => {
|
{buildMentionHighlightNodes(
|
||||||
const text = message.content || ''
|
message.content || '',
|
||||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
message.contexts || [],
|
||||||
? ((message as any).contexts as any[])
|
(token, key) => (
|
||||||
: []
|
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
|
||||||
|
{token}
|
||||||
// Build tokens with their prefixes (@ for mentions, / for commands)
|
</span>
|
||||||
const tokens = contexts
|
)
|
||||||
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
|
)}
|
||||||
.map((c) => {
|
|
||||||
const prefix = c?.kind === 'slash_command' ? '/' : '@'
|
|
||||||
return `${prefix}${c.label}`
|
|
||||||
})
|
|
||||||
if (!tokens.length) return text
|
|
||||||
|
|
||||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
|
||||||
|
|
||||||
const nodes: React.ReactNode[] = []
|
|
||||||
let lastIndex = 0
|
|
||||||
let match: RegExpExecArray | null
|
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
|
||||||
const i = match.index
|
|
||||||
const before = text.slice(lastIndex, i)
|
|
||||||
if (before) nodes.push(before)
|
|
||||||
const mention = match[0]
|
|
||||||
nodes.push(
|
|
||||||
<span
|
|
||||||
key={`mention-${i}-${lastIndex}`}
|
|
||||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
|
||||||
>
|
|
||||||
{mention}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
lastIndex = i + mention.length
|
|
||||||
}
|
|
||||||
const tail = text.slice(lastIndex)
|
|
||||||
if (tail) nodes.push(tail)
|
|
||||||
return nodes
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gradient fade when truncated - applies to entire message box */}
|
{/* Gradient fade when truncated - applies to entire message box */}
|
||||||
@@ -439,50 +413,16 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
|
|
||||||
{/* Inline Restore Checkpoint Confirmation */}
|
{/* Inline Restore Checkpoint Confirmation */}
|
||||||
{showRestoreConfirmation && (
|
{showRestoreConfirmation && (
|
||||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
<RestoreCheckpointModal
|
||||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
isReverting={isReverting}
|
||||||
Revert to checkpoint? This will restore your workflow to the state saved at this
|
onCancel={handleCancelRevert}
|
||||||
checkpoint.{' '}
|
onConfirm={handleConfirmRevert}
|
||||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
/>
|
||||||
</p>
|
|
||||||
<div className='flex gap-[8px]'>
|
|
||||||
<Button
|
|
||||||
onClick={handleCancelRevert}
|
|
||||||
variant='active'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isReverting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmRevert}
|
|
||||||
variant='destructive'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isReverting}
|
|
||||||
>
|
|
||||||
{isReverting ? 'Reverting...' : 'Revert'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's any visible content in the blocks
|
|
||||||
const hasVisibleContent = useMemo(() => {
|
|
||||||
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
|
||||||
return message.contentBlocks.some((block) => {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
const parsed = parseSpecialTags(block.content)
|
|
||||||
return parsed.cleanContent.trim().length > 0
|
|
||||||
}
|
|
||||||
return block.type === 'thinking' || block.type === 'tool_call'
|
|
||||||
})
|
|
||||||
}, [message.contentBlocks])
|
|
||||||
|
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -534,6 +474,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||||
}
|
}
|
||||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||||
|
selectedOptionKey={selectedOptionKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||||
|
export { useMessageContentAnalysis } from './use-message-content-analysis'
|
||||||
export { useMessageEditing } from './use-message-editing'
|
export { useMessageEditing } from './use-message-editing'
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||||
|
import type { CopilotMessage } from '@/stores/panel'
|
||||||
|
|
||||||
|
interface UseMessageContentAnalysisProps {
|
||||||
|
message: CopilotMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to analyze message content blocks for visibility and content
|
||||||
|
* Determines if there's any visible content to display
|
||||||
|
*
|
||||||
|
* @param props - Configuration containing the message to analyze
|
||||||
|
* @returns Object containing visibility analysis results
|
||||||
|
*/
|
||||||
|
export function useMessageContentAnalysis({ message }: UseMessageContentAnalysisProps) {
|
||||||
|
const hasVisibleContent = useMemo(() => {
|
||||||
|
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
||||||
|
return message.contentBlocks.some((block) => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
const parsed = parseSpecialTags(block.content)
|
||||||
|
return parsed.cleanContent.trim().length > 0
|
||||||
|
}
|
||||||
|
return block.type === 'thinking' || block.type === 'tool_call'
|
||||||
|
})
|
||||||
|
}, [message.contentBlocks])
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasVisibleContent,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { CopilotMessage } from '@/stores/panel'
|
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
const logger = createLogger('useMessageEditing')
|
const logger = createLogger('useMessageEditing')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref interface for UserInput component
|
||||||
|
*/
|
||||||
|
interface UserInputRef {
|
||||||
|
focus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message truncation height in pixels
|
* Message truncation height in pixels
|
||||||
*/
|
*/
|
||||||
@@ -32,8 +39,8 @@ interface UseMessageEditingProps {
|
|||||||
setShowCheckpointDiscardModal: (show: boolean) => void
|
setShowCheckpointDiscardModal: (show: boolean) => void
|
||||||
pendingEditRef: React.MutableRefObject<{
|
pendingEditRef: React.MutableRefObject<{
|
||||||
message: string
|
message: string
|
||||||
fileAttachments?: any[]
|
fileAttachments?: MessageFileAttachment[]
|
||||||
contexts?: any[]
|
contexts?: ChatContext[]
|
||||||
} | null>
|
} | null>
|
||||||
/**
|
/**
|
||||||
* When true, disables the internal document click-outside handler.
|
* When true, disables the internal document click-outside handler.
|
||||||
@@ -69,7 +76,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
|
|
||||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||||
const userInputRef = useRef<any>(null)
|
const userInputRef = useRef<UserInputRef>(null)
|
||||||
|
|
||||||
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
|
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
|
||||||
|
|
||||||
@@ -121,7 +128,11 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
* Truncates messages after edited message and resends with same ID
|
* Truncates messages after edited message and resends with same ID
|
||||||
*/
|
*/
|
||||||
const performEdit = useCallback(
|
const performEdit = useCallback(
|
||||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
async (
|
||||||
|
editedMessage: string,
|
||||||
|
fileAttachments?: MessageFileAttachment[],
|
||||||
|
contexts?: ChatContext[]
|
||||||
|
) => {
|
||||||
const currentMessages = messages
|
const currentMessages = messages
|
||||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||||
|
|
||||||
@@ -134,7 +145,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
...message,
|
...message,
|
||||||
content: editedMessage,
|
content: editedMessage,
|
||||||
fileAttachments: fileAttachments || message.fileAttachments,
|
fileAttachments: fileAttachments || message.fileAttachments,
|
||||||
contexts: contexts || (message as any).contexts,
|
contexts: contexts || message.contexts,
|
||||||
}
|
}
|
||||||
|
|
||||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||||
@@ -153,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||||
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
||||||
...((m as any).contexts && { contexts: (m as any).contexts }),
|
...(m.contexts && { contexts: m.contexts }),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -164,7 +175,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
|
|
||||||
await sendMessage(editedMessage, {
|
await sendMessage(editedMessage, {
|
||||||
fileAttachments: fileAttachments || message.fileAttachments,
|
fileAttachments: fileAttachments || message.fileAttachments,
|
||||||
contexts: contexts || (message as any).contexts,
|
contexts: contexts || message.contexts,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
queueIfBusy: false,
|
queueIfBusy: false,
|
||||||
})
|
})
|
||||||
@@ -178,7 +189,11 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
* Checks for checkpoints and shows confirmation if needed
|
* Checks for checkpoints and shows confirmation if needed
|
||||||
*/
|
*/
|
||||||
const handleSubmitEdit = useCallback(
|
const handleSubmitEdit = useCallback(
|
||||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
async (
|
||||||
|
editedMessage: string,
|
||||||
|
fileAttachments?: MessageFileAttachment[],
|
||||||
|
contexts?: ChatContext[]
|
||||||
|
) => {
|
||||||
if (!editedMessage.trim()) return
|
if (!editedMessage.trim()) return
|
||||||
|
|
||||||
if (isSendingMessage) {
|
if (isSendingMessage) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './copilot-message'
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
export * from './copilot-message/copilot-message'
|
export * from './chat-history-skeleton'
|
||||||
export * from './plan-mode-section/plan-mode-section'
|
export * from './copilot-message'
|
||||||
export * from './queued-messages/queued-messages'
|
export * from './plan-mode-section'
|
||||||
export * from './todo-list/todo-list'
|
export * from './queued-messages'
|
||||||
export * from './tool-call/tool-call'
|
export * from './todo-list'
|
||||||
export * from './user-input/user-input'
|
export * from './tool-call'
|
||||||
export * from './welcome/welcome'
|
export * from './user-input'
|
||||||
|
export * from './welcome'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './plan-mode-section'
|
||||||
@@ -29,7 +29,7 @@ import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
|
|||||||
import { Button, Textarea } from '@/components/emcn'
|
import { Button, Textarea } from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared border and background styles
|
* Shared border and background styles
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './queued-messages'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './todo-list'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './tool-call'
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
hasInterrupt as hasInterruptFromConfig,
|
hasInterrupt as hasInterruptFromConfig,
|
||||||
isSpecialTool as isSpecialToolFromConfig,
|
isSpecialTool as isSpecialToolFromConfig,
|
||||||
} from '@/lib/copilot/tools/client/ui-config'
|
} from '@/lib/copilot/tools/client/ui-config'
|
||||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||||
@@ -244,6 +244,7 @@ export function OptionsSelector({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
enableKeyboardNav = false,
|
enableKeyboardNav = false,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
|
selectedOptionKey = null,
|
||||||
}: {
|
}: {
|
||||||
options: Record<string, OptionItem>
|
options: Record<string, OptionItem>
|
||||||
onSelect: (optionKey: string, optionText: string) => void
|
onSelect: (optionKey: string, optionText: string) => void
|
||||||
@@ -252,6 +253,8 @@ export function OptionsSelector({
|
|||||||
enableKeyboardNav?: boolean
|
enableKeyboardNav?: boolean
|
||||||
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
|
/** Pre-selected option key (for restoring selection from history) */
|
||||||
|
selectedOptionKey?: string | null
|
||||||
}) {
|
}) {
|
||||||
const isInteractionDisabled = disabled || streaming
|
const isInteractionDisabled = disabled || streaming
|
||||||
const sortedOptions = useMemo(() => {
|
const sortedOptions = useMemo(() => {
|
||||||
@@ -270,7 +273,7 @@ export function OptionsSelector({
|
|||||||
}, [options])
|
}, [options])
|
||||||
|
|
||||||
const [hoveredIndex, setHoveredIndex] = useState(0)
|
const [hoveredIndex, setHoveredIndex] = useState(0)
|
||||||
const [chosenKey, setChosenKey] = useState<string | null>(null)
|
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const isLocked = chosenKey !== null
|
const isLocked = chosenKey !== null
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './attached-files-display'
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ArrowUp, Image, Loader2 } from 'lucide-react'
|
||||||
|
import { Badge, Button } from '@/components/emcn'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { ModeSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/mode-selector'
|
||||||
|
import { ModelSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector'
|
||||||
|
|
||||||
|
interface BottomControlsProps {
|
||||||
|
mode: 'ask' | 'build' | 'plan'
|
||||||
|
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
|
||||||
|
selectedModel: string
|
||||||
|
onModelSelect: (model: string) => void
|
||||||
|
isNearTop: boolean
|
||||||
|
disabled: boolean
|
||||||
|
hideModeSelector: boolean
|
||||||
|
canSubmit: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
isAborting: boolean
|
||||||
|
showAbortButton: boolean
|
||||||
|
onSubmit: () => void
|
||||||
|
onAbort: () => void
|
||||||
|
onFileSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom controls section of the user input
|
||||||
|
* Contains mode selector, model selector, file attachment button, and submit/abort buttons
|
||||||
|
*/
|
||||||
|
export function BottomControls({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
selectedModel,
|
||||||
|
onModelSelect,
|
||||||
|
isNearTop,
|
||||||
|
disabled,
|
||||||
|
hideModeSelector,
|
||||||
|
canSubmit,
|
||||||
|
isLoading,
|
||||||
|
isAborting,
|
||||||
|
showAbortButton,
|
||||||
|
onSubmit,
|
||||||
|
onAbort,
|
||||||
|
onFileSelect,
|
||||||
|
}: BottomControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
{/* Left side: Mode Selector + Model Selector */}
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
|
{!hideModeSelector && (
|
||||||
|
<ModeSelector
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={onModeChange}
|
||||||
|
isNearTop={isNearTop}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
isNearTop={isNearTop}
|
||||||
|
onModelSelect={onModelSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Attach Button + Send Button */}
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
||||||
|
<Badge
|
||||||
|
onClick={onFileSelect}
|
||||||
|
title='Attach file'
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{showAbortButton ? (
|
||||||
|
<Button
|
||||||
|
onClick={onAbort}
|
||||||
|
disabled={isAborting}
|
||||||
|
className={cn(
|
||||||
|
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
||||||
|
!isAborting
|
||||||
|
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||||
|
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
||||||
|
)}
|
||||||
|
title='Stop generation'
|
||||||
|
>
|
||||||
|
{isAborting ? (
|
||||||
|
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||||
|
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
||||||
|
) : (
|
||||||
|
<ArrowUp
|
||||||
|
className='block h-3.5 w-3.5 text-white dark:text-black'
|
||||||
|
strokeWidth={2.25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './bottom-controls'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './context-pills'
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
export { AttachedFilesDisplay } from './attached-files-display'
|
||||||
export { ContextPills } from './context-pills/context-pills'
|
export { BottomControls } from './bottom-controls'
|
||||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
export { ContextPills } from './context-pills'
|
||||||
export { ModeSelector } from './mode-selector/mode-selector'
|
export { type MentionFolderNav, MentionMenu } from './mention-menu'
|
||||||
export { ModelSelector } from './model-selector/model-selector'
|
export { ModeSelector } from './mode-selector'
|
||||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
export { ModelSelector } from './model-selector'
|
||||||
|
export { type SlashFolderNav, SlashMenu } from './slash-menu'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mention-menu'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mode-selector'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './model-selector'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './slash-menu'
|
||||||
@@ -5,5 +5,6 @@ export { useMentionData } from './use-mention-data'
|
|||||||
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
|
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
|
||||||
export { useMentionKeyboard } from './use-mention-keyboard'
|
export { useMentionKeyboard } from './use-mention-keyboard'
|
||||||
export { useMentionMenu } from './use-mention-menu'
|
export { useMentionMenu } from './use-mention-menu'
|
||||||
|
export { useMentionSystem } from './use-mention-system'
|
||||||
export { useMentionTokens } from './use-mention-tokens'
|
export { useMentionTokens } from './use-mention-tokens'
|
||||||
export { useTextareaAutoResize } from './use-textarea-auto-resize'
|
export { useTextareaAutoResize } from './use-textarea-auto-resize'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
escapeRegex,
|
||||||
filterOutContext,
|
filterOutContext,
|
||||||
isContextAlreadySelected,
|
isContextAlreadySelected,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||||
@@ -22,9 +23,6 @@ interface UseContextManagementProps {
|
|||||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
||||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||||
const initializedRef = useRef(false)
|
const initializedRef = useRef(false)
|
||||||
const escapeRegex = useCallback((value: string) => {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
// Initialize with initial contexts when they're first provided (for edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||||
|
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
|
||||||
|
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
||||||
|
import { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
||||||
|
import { useMentionInsertHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers'
|
||||||
|
import { useMentionKeyboard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard'
|
||||||
|
import { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
|
||||||
|
import { useMentionTokens } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens'
|
||||||
|
import { useTextareaAutoResize } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize'
|
||||||
|
import type { ChatContext } from '@/stores/panel'
|
||||||
|
|
||||||
|
interface UseMentionSystemProps {
|
||||||
|
message: string
|
||||||
|
setMessage: (message: string) => void
|
||||||
|
workflowId: string | null
|
||||||
|
workspaceId: string
|
||||||
|
userId?: string
|
||||||
|
panelWidth: number
|
||||||
|
disabled: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
inputContainerRef: HTMLDivElement | null
|
||||||
|
initialContexts?: ChatContext[]
|
||||||
|
mentionFolderNav: MentionFolderNav | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite hook that combines all mention-related hooks into a single interface.
|
||||||
|
* Reduces import complexity in components that need full mention functionality.
|
||||||
|
*
|
||||||
|
* @param props - Configuration for all mention system hooks
|
||||||
|
* @returns Combined interface for mention system functionality
|
||||||
|
*/
|
||||||
|
export function useMentionSystem({
|
||||||
|
message,
|
||||||
|
setMessage,
|
||||||
|
workflowId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
panelWidth,
|
||||||
|
disabled,
|
||||||
|
isLoading,
|
||||||
|
inputContainerRef,
|
||||||
|
initialContexts,
|
||||||
|
mentionFolderNav,
|
||||||
|
}: UseMentionSystemProps) {
|
||||||
|
const contextManagement = useContextManagement({ message, initialContexts })
|
||||||
|
|
||||||
|
const mentionMenu = useMentionMenu({
|
||||||
|
message,
|
||||||
|
selectedContexts: contextManagement.selectedContexts,
|
||||||
|
onContextSelect: contextManagement.addContext,
|
||||||
|
onMessageChange: setMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mentionTokens = useMentionTokens({
|
||||||
|
message,
|
||||||
|
selectedContexts: contextManagement.selectedContexts,
|
||||||
|
mentionMenu,
|
||||||
|
setMessage,
|
||||||
|
setSelectedContexts: contextManagement.setSelectedContexts,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { overlayRef } = useTextareaAutoResize({
|
||||||
|
message,
|
||||||
|
panelWidth,
|
||||||
|
selectedContexts: contextManagement.selectedContexts,
|
||||||
|
textareaRef: mentionMenu.textareaRef,
|
||||||
|
containerRef: inputContainerRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mentionData = useMentionData({
|
||||||
|
workflowId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileAttachments = useFileAttachments({
|
||||||
|
userId,
|
||||||
|
disabled,
|
||||||
|
isLoading,
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertHandlers = useMentionInsertHandlers({
|
||||||
|
mentionMenu,
|
||||||
|
workflowId,
|
||||||
|
selectedContexts: contextManagement.selectedContexts,
|
||||||
|
onContextAdd: contextManagement.addContext,
|
||||||
|
mentionFolderNav,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mentionKeyboard = useMentionKeyboard({
|
||||||
|
mentionMenu,
|
||||||
|
mentionData,
|
||||||
|
insertHandlers,
|
||||||
|
mentionFolderNav,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextManagement,
|
||||||
|
mentionMenu,
|
||||||
|
mentionTokens,
|
||||||
|
overlayRef,
|
||||||
|
mentionData,
|
||||||
|
fileAttachments,
|
||||||
|
insertHandlers,
|
||||||
|
mentionKeyboard,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './user-input'
|
||||||
@@ -9,19 +9,19 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
|
import { AtSign } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Badge, Button, Textarea } from '@/components/emcn'
|
import { Badge, Button, Textarea } from '@/components/emcn'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
|
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
AttachedFilesDisplay,
|
AttachedFilesDisplay,
|
||||||
|
BottomControls,
|
||||||
ContextPills,
|
ContextPills,
|
||||||
type MentionFolderNav,
|
type MentionFolderNav,
|
||||||
MentionMenu,
|
MentionMenu,
|
||||||
ModelSelector,
|
|
||||||
ModeSelector,
|
|
||||||
type SlashFolderNav,
|
type SlashFolderNav,
|
||||||
SlashMenu,
|
SlashMenu,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
useTextareaAutoResize,
|
useTextareaAutoResize,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
|
||||||
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
||||||
|
import {
|
||||||
|
computeMentionHighlightRanges,
|
||||||
|
extractContextTokens,
|
||||||
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||||
import type { ChatContext } from '@/stores/panel'
|
import type { ChatContext } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
@@ -306,7 +310,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
size: f.size,
|
size: f.size,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
|
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
|
||||||
|
|
||||||
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
||||||
if (shouldClearInput) {
|
if (shouldClearInput) {
|
||||||
@@ -657,7 +661,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model: string) => {
|
(model: string) => {
|
||||||
setSelectedModel(model as any)
|
setSelectedModel(model as CopilotModelId)
|
||||||
},
|
},
|
||||||
[setSelectedModel]
|
[setSelectedModel]
|
||||||
)
|
)
|
||||||
@@ -677,15 +681,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
return <span>{displayText}</span>
|
return <span>{displayText}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements: React.ReactNode[] = []
|
const tokens = extractContextTokens(contexts)
|
||||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
const ranges = computeMentionHighlightRanges(message, tokens)
|
||||||
|
|
||||||
if (ranges.length === 0) {
|
if (ranges.length === 0) {
|
||||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||||
return <span>{displayText}</span>
|
return <span>{displayText}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elements: React.ReactNode[] = []
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
|
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
const range = ranges[i]
|
const range = ranges[i]
|
||||||
|
|
||||||
@@ -694,13 +700,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionText = message.slice(range.start, range.end)
|
|
||||||
elements.push(
|
elements.push(
|
||||||
<span
|
<span
|
||||||
key={`mention-${i}-${range.start}-${range.end}`}
|
key={`mention-${i}-${range.start}-${range.end}`}
|
||||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||||
>
|
>
|
||||||
{mentionText}
|
{range.token}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
lastIndex = range.end
|
lastIndex = range.end
|
||||||
@@ -713,7 +718,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||||
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
}, [message, contextManagement.selectedContexts])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -855,87 +860,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||||
<div className='flex items-center justify-between gap-2'>
|
<BottomControls
|
||||||
{/* Left side: Mode Selector + Model Selector */}
|
mode={mode}
|
||||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
onModeChange={onModeChange}
|
||||||
{!hideModeSelector && (
|
selectedModel={selectedModel}
|
||||||
<ModeSelector
|
onModelSelect={handleModelSelect}
|
||||||
mode={mode}
|
isNearTop={isNearTop}
|
||||||
onModeChange={onModeChange}
|
disabled={disabled}
|
||||||
isNearTop={isNearTop}
|
hideModeSelector={hideModeSelector}
|
||||||
disabled={disabled}
|
canSubmit={canSubmit}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
)}
|
isAborting={isAborting}
|
||||||
|
showAbortButton={Boolean(showAbortButton)}
|
||||||
<ModelSelector
|
onSubmit={() => void handleSubmit()}
|
||||||
selectedModel={selectedModel}
|
onAbort={handleAbort}
|
||||||
isNearTop={isNearTop}
|
onFileSelect={fileAttachments.handleFileSelect}
|
||||||
onModelSelect={handleModelSelect}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side: Attach Button + Send Button */}
|
|
||||||
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
|
||||||
<Badge
|
|
||||||
onClick={fileAttachments.handleFileSelect}
|
|
||||||
title='Attach file'
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
|
||||||
disabled && 'cursor-not-allowed opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{showAbortButton ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleAbort}
|
|
||||||
disabled={isAborting}
|
|
||||||
className={cn(
|
|
||||||
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
|
||||||
!isAborting
|
|
||||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
|
||||||
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
|
||||||
)}
|
|
||||||
title='Stop generation'
|
|
||||||
>
|
|
||||||
{isAborting ? (
|
|
||||||
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
|
||||||
viewBox='0 0 24 24'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
>
|
|
||||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
void handleSubmit()
|
|
||||||
}}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className={cn(
|
|
||||||
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
|
||||||
canSubmit
|
|
||||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
|
||||||
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
|
||||||
) : (
|
|
||||||
<ArrowUp
|
|
||||||
className='block h-3.5 w-3.5 text-white dark:text-black'
|
|
||||||
strokeWidth={2.25}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
FOLDER_CONFIGS,
|
FOLDER_CONFIGS,
|
||||||
type MentionFolderId,
|
type MentionFolderId,
|
||||||
@@ -5,6 +6,102 @@ import {
|
|||||||
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
||||||
import type { ChatContext } from '@/stores/panel'
|
import type { ChatContext } from '@/stores/panel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special regex characters in a string
|
||||||
|
* @param value - String to escape
|
||||||
|
* @returns Escaped string safe for use in RegExp
|
||||||
|
*/
|
||||||
|
export function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts mention tokens from contexts for display/matching
|
||||||
|
* Filters out current_workflow contexts and builds prefixed labels
|
||||||
|
* @param contexts - Array of chat contexts
|
||||||
|
* @returns Array of prefixed token strings (e.g., "@workflow", "/web")
|
||||||
|
*/
|
||||||
|
export function extractContextTokens(contexts: ChatContext[]): string[] {
|
||||||
|
return contexts
|
||||||
|
.filter((c) => c.kind !== 'current_workflow' && c.label)
|
||||||
|
.map((c) => {
|
||||||
|
const prefix = c.kind === 'slash_command' ? '/' : '@'
|
||||||
|
return `${prefix}${c.label}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention range for text highlighting
|
||||||
|
*/
|
||||||
|
export interface MentionHighlightRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes mention ranges in text for highlighting
|
||||||
|
* @param text - Text to search
|
||||||
|
* @param tokens - Prefixed tokens to find (e.g., "@workflow", "/web")
|
||||||
|
* @returns Array of ranges with start, end, and matched token
|
||||||
|
*/
|
||||||
|
export function computeMentionHighlightRanges(
|
||||||
|
text: string,
|
||||||
|
tokens: string[]
|
||||||
|
): MentionHighlightRange[] {
|
||||||
|
if (!tokens.length || !text) return []
|
||||||
|
|
||||||
|
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
||||||
|
const ranges: MentionHighlightRange[] = []
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
ranges.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
token: match[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds React nodes with highlighted mention tokens
|
||||||
|
* @param text - Text to render
|
||||||
|
* @param contexts - Chat contexts to highlight
|
||||||
|
* @param createHighlightSpan - Function to create highlighted span element
|
||||||
|
* @returns Array of React nodes with highlighted mentions
|
||||||
|
*/
|
||||||
|
export function buildMentionHighlightNodes(
|
||||||
|
text: string,
|
||||||
|
contexts: ChatContext[],
|
||||||
|
createHighlightSpan: (token: string, key: string) => ReactNode
|
||||||
|
): ReactNode[] {
|
||||||
|
const tokens = extractContextTokens(contexts)
|
||||||
|
if (!tokens.length) return [text]
|
||||||
|
|
||||||
|
const ranges = computeMentionHighlightRanges(text, tokens)
|
||||||
|
if (!ranges.length) return [text]
|
||||||
|
|
||||||
|
const nodes: ReactNode[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (range.start > lastIndex) {
|
||||||
|
nodes.push(text.slice(lastIndex, range.start))
|
||||||
|
}
|
||||||
|
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
|
||||||
|
lastIndex = range.end
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(text.slice(lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the data array for a folder ID from mentionData.
|
* Gets the data array for a folder ID from mentionData.
|
||||||
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
|
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './welcome'
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
|
ChatHistorySkeleton,
|
||||||
CopilotMessage,
|
CopilotMessage,
|
||||||
PlanModeSection,
|
PlanModeSection,
|
||||||
QueuedMessages,
|
QueuedMessages,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
useTodoManagement,
|
useTodoManagement,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
||||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
|
import type { ChatContext } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
@@ -74,10 +76,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||||
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
|
||||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||||
|
|
||||||
|
// Derived state - editing when there's an editingMessageId
|
||||||
|
const isEditingMessage = editingMessageId !== null
|
||||||
|
|
||||||
const { activeWorkflowId } = useWorkflowRegistry()
|
const { activeWorkflowId } = useWorkflowRegistry()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -106,9 +110,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
areChatsFresh,
|
areChatsFresh,
|
||||||
workflowId: copilotWorkflowId,
|
workflowId: copilotWorkflowId,
|
||||||
setPlanTodos,
|
setPlanTodos,
|
||||||
|
closePlanTodos,
|
||||||
clearPlanArtifact,
|
clearPlanArtifact,
|
||||||
savePlanArtifact,
|
savePlanArtifact,
|
||||||
setSelectedModel,
|
|
||||||
loadAutoAllowedTools,
|
loadAutoAllowedTools,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
@@ -292,6 +296,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
}
|
}
|
||||||
}, [abortMessage, showPlanTodos])
|
}, [abortMessage, showPlanTodos])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles closing the plan todos section
|
||||||
|
* Calls store action and clears the todos
|
||||||
|
*/
|
||||||
|
const handleClosePlanTodos = useCallback(() => {
|
||||||
|
closePlanTodos()
|
||||||
|
setPlanTodos([])
|
||||||
|
}, [closePlanTodos, setPlanTodos])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles message submission to the copilot
|
* Handles message submission to the copilot
|
||||||
* @param query - The message text to send
|
* @param query - The message text to send
|
||||||
@@ -299,13 +312,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
* @param contexts - Optional context references
|
* @param contexts - Optional context references
|
||||||
*/
|
*/
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => {
|
||||||
// Allow submission even when isSendingMessage - store will queue the message
|
// Allow submission even when isSendingMessage - store will queue the message
|
||||||
if (!query || !activeWorkflowId) return
|
if (!query || !activeWorkflowId) return
|
||||||
|
|
||||||
if (showPlanTodos) {
|
if (showPlanTodos) {
|
||||||
const store = useCopilotStore.getState()
|
setPlanTodos([])
|
||||||
store.setPlanTodos([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -319,7 +331,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
logger.error('Failed to send message:', error)
|
logger.error('Failed to send message:', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeWorkflowId, sendMessage, showPlanTodos]
|
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,7 +342,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
const handleEditModeChange = useCallback(
|
const handleEditModeChange = useCallback(
|
||||||
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
||||||
setEditingMessageId(isEditing ? messageId : null)
|
setEditingMessageId(isEditing ? messageId : null)
|
||||||
setIsEditingMessage(isEditing)
|
|
||||||
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
||||||
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
||||||
},
|
},
|
||||||
@@ -375,24 +386,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
[handleHistoryDropdownOpenHook]
|
[handleHistoryDropdownOpenHook]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton loading component for chat history
|
|
||||||
*/
|
|
||||||
const ChatHistorySkeleton = () => (
|
|
||||||
<>
|
|
||||||
<PopoverSection>
|
|
||||||
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
|
||||||
</PopoverSection>
|
|
||||||
<div className='flex flex-col gap-0.5'>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -588,11 +581,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
<TodoList
|
<TodoList
|
||||||
todos={planTodos}
|
todos={planTodos}
|
||||||
collapsed={todosCollapsed}
|
collapsed={todosCollapsed}
|
||||||
onClose={() => {
|
onClose={handleClosePlanTodos}
|
||||||
const store = useCopilotStore.getState()
|
|
||||||
store.closePlanTodos?.()
|
|
||||||
useCopilotStore.setState({ planTodos: [] })
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
loadChats(false)
|
loadChats(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle race condition: chats loaded for wrong workflow during initial load
|
||||||
|
// This happens when user navigates before initial loadChats completes
|
||||||
|
if (
|
||||||
|
activeWorkflowId &&
|
||||||
|
!isLoadingChats &&
|
||||||
|
chatsLoadedForWorkflow !== null &&
|
||||||
|
chatsLoadedForWorkflow !== activeWorkflowId &&
|
||||||
|
!isSendingMessage
|
||||||
|
) {
|
||||||
|
logger.info('Chats loaded for wrong workflow, reloading', {
|
||||||
|
loaded: chatsLoadedForWorkflow,
|
||||||
|
active: activeWorkflowId,
|
||||||
|
})
|
||||||
|
setIsInitialized(false)
|
||||||
|
lastWorkflowIdRef.current = activeWorkflowId
|
||||||
|
setCopilotWorkflowId(activeWorkflowId)
|
||||||
|
loadChats(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as initialized when chats are loaded for the active workflow
|
// Mark as initialized when chats are loaded for the active workflow
|
||||||
if (
|
if (
|
||||||
activeWorkflowId &&
|
activeWorkflowId &&
|
||||||
|
|||||||
@@ -1,281 +1,17 @@
|
|||||||
import type { RefObject } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
|
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
|
||||||
import {
|
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
|
||||||
checkTagTrigger,
|
|
||||||
TagDropdown,
|
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||||
import { formatParameterLabel } from '@/tools/params'
|
import { formatParameterLabel } from '@/tools/params'
|
||||||
|
|
||||||
const logger = createLogger('McpDynamicArgs')
|
const logger = createLogger('McpDynamicArgs')
|
||||||
|
|
||||||
interface McpInputWithTagsProps {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
isPassword?: boolean
|
|
||||||
blockId: string
|
|
||||||
accessiblePrefixes?: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function McpInputWithTags({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled,
|
|
||||||
isPassword,
|
|
||||||
blockId,
|
|
||||||
accessiblePrefixes,
|
|
||||||
}: McpInputWithTagsProps) {
|
|
||||||
const [showTags, setShowTags] = useState(false)
|
|
||||||
const [cursorPosition, setCursorPosition] = useState(0)
|
|
||||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
const newCursorPosition = e.target.selectionStart ?? 0
|
|
||||||
|
|
||||||
onChange(newValue)
|
|
||||||
setCursorPosition(newCursorPosition)
|
|
||||||
|
|
||||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
|
||||||
setShowTags(tagTrigger.show)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
|
||||||
if (data.type !== 'connectionBlock') return
|
|
||||||
|
|
||||||
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
|
|
||||||
const currentValue = value ?? ''
|
|
||||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
|
||||||
|
|
||||||
onChange(newValue)
|
|
||||||
setCursorPosition(dropPosition + 1)
|
|
||||||
setShowTags(true)
|
|
||||||
|
|
||||||
if (data.connectionData?.sourceBlockId) {
|
|
||||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.selectionStart = dropPosition + 1
|
|
||||||
inputRef.current.selectionEnd = dropPosition + 1
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse drop data:', { error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTagSelect = (newValue: string) => {
|
|
||||||
onChange(newValue)
|
|
||||||
setShowTags(false)
|
|
||||||
setActiveSourceBlockId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='relative'>
|
|
||||||
<div className='relative'>
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type={isPassword ? 'password' : 'text'}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
name={inputNameRef.current}
|
|
||||||
autoComplete='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
spellCheck='false'
|
|
||||||
data-form-type='other'
|
|
||||||
data-lpignore='true'
|
|
||||||
data-1p-ignore
|
|
||||||
readOnly
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.removeAttribute('readOnly')
|
|
||||||
// Show tag dropdown on focus when input is empty
|
|
||||||
if (!disabled && (value?.trim() === '' || !value)) {
|
|
||||||
setShowTags(true)
|
|
||||||
setCursorPosition(0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(!isPassword && 'text-transparent caret-foreground')}
|
|
||||||
/>
|
|
||||||
{!isPassword && (
|
|
||||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
|
|
||||||
<div className='whitespace-pre'>
|
|
||||||
{formatDisplayText(value?.toString() || '', {
|
|
||||||
accessiblePrefixes,
|
|
||||||
highlightAll: !accessiblePrefixes,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TagDropdown
|
|
||||||
visible={showTags}
|
|
||||||
onSelect={handleTagSelect}
|
|
||||||
blockId={blockId}
|
|
||||||
activeSourceBlockId={activeSourceBlockId}
|
|
||||||
inputValue={value?.toString() ?? ''}
|
|
||||||
cursorPosition={cursorPosition}
|
|
||||||
onClose={() => {
|
|
||||||
setShowTags(false)
|
|
||||||
setActiveSourceBlockId(null)
|
|
||||||
}}
|
|
||||||
inputRef={inputRef as RefObject<HTMLInputElement>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface McpTextareaWithTagsProps {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
blockId: string
|
|
||||||
accessiblePrefixes?: Set<string>
|
|
||||||
rows?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function McpTextareaWithTags({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled,
|
|
||||||
blockId,
|
|
||||||
accessiblePrefixes,
|
|
||||||
rows = 4,
|
|
||||||
}: McpTextareaWithTagsProps) {
|
|
||||||
const [showTags, setShowTags] = useState(false)
|
|
||||||
const [cursorPosition, setCursorPosition] = useState(0)
|
|
||||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
const newCursorPosition = e.target.selectionStart ?? 0
|
|
||||||
|
|
||||||
onChange(newValue)
|
|
||||||
setCursorPosition(newCursorPosition)
|
|
||||||
|
|
||||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
|
||||||
setShowTags(tagTrigger.show)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
|
||||||
if (data.type !== 'connectionBlock') return
|
|
||||||
|
|
||||||
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
|
|
||||||
const currentValue = value ?? ''
|
|
||||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
|
||||||
|
|
||||||
onChange(newValue)
|
|
||||||
setCursorPosition(dropPosition + 1)
|
|
||||||
setShowTags(true)
|
|
||||||
|
|
||||||
if (data.connectionData?.sourceBlockId) {
|
|
||||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.selectionStart = dropPosition + 1
|
|
||||||
textareaRef.current.selectionEnd = dropPosition + 1
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse drop data:', { error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTagSelect = (newValue: string) => {
|
|
||||||
onChange(newValue)
|
|
||||||
setShowTags(false)
|
|
||||||
setActiveSourceBlockId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='relative'>
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onFocus={() => {
|
|
||||||
// Show tag dropdown on focus when input is empty
|
|
||||||
if (!disabled && (value?.trim() === '' || !value)) {
|
|
||||||
setShowTags(true)
|
|
||||||
setCursorPosition(0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
rows={rows}
|
|
||||||
name={textareaNameRef.current}
|
|
||||||
autoComplete='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
spellCheck='false'
|
|
||||||
data-form-type='other'
|
|
||||||
data-lpignore='true'
|
|
||||||
data-1p-ignore
|
|
||||||
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
|
|
||||||
/>
|
|
||||||
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
|
|
||||||
{formatDisplayText(value || '', {
|
|
||||||
accessiblePrefixes,
|
|
||||||
highlightAll: !accessiblePrefixes,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<TagDropdown
|
|
||||||
visible={showTags}
|
|
||||||
onSelect={handleTagSelect}
|
|
||||||
blockId={blockId}
|
|
||||||
activeSourceBlockId={activeSourceBlockId}
|
|
||||||
inputValue={value?.toString() ?? ''}
|
|
||||||
cursorPosition={cursorPosition}
|
|
||||||
onClose={() => {
|
|
||||||
setShowTags(false)
|
|
||||||
setActiveSourceBlockId(null)
|
|
||||||
}}
|
|
||||||
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface McpDynamicArgsProps {
|
interface McpDynamicArgsProps {
|
||||||
blockId: string
|
blockId: string
|
||||||
subBlockId: string
|
subBlockId: string
|
||||||
@@ -284,6 +20,27 @@ interface McpDynamicArgsProps {
|
|||||||
previewValue?: any
|
previewValue?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal SubBlockConfig for MCP tool parameters
|
||||||
|
*/
|
||||||
|
function createParamConfig(
|
||||||
|
paramName: string,
|
||||||
|
paramSchema: any,
|
||||||
|
inputType: 'long-input' | 'short-input'
|
||||||
|
): SubBlockConfig {
|
||||||
|
const placeholder =
|
||||||
|
paramSchema.type === 'array'
|
||||||
|
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||||
|
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: paramName,
|
||||||
|
type: inputType,
|
||||||
|
title: formatParameterLabel(paramName),
|
||||||
|
placeholder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function McpDynamicArgs({
|
export function McpDynamicArgs({
|
||||||
blockId,
|
blockId,
|
||||||
subBlockId,
|
subBlockId,
|
||||||
@@ -297,7 +54,6 @@ export function McpDynamicArgs({
|
|||||||
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
||||||
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
||||||
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
|
||||||
|
|
||||||
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
||||||
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
||||||
@@ -308,7 +64,7 @@ export function McpDynamicArgs({
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(previewValue)
|
return JSON.parse(previewValue)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse preview value as JSON:', error)
|
logger.warn('Failed to parse preview value as JSON:', { error })
|
||||||
return previewValue
|
return previewValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +74,7 @@ export function McpDynamicArgs({
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(toolArgs)
|
return JSON.parse(toolArgs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse toolArgs as JSON:', error)
|
logger.warn('Failed to parse toolArgs as JSON:', { error })
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,24 +216,23 @@ export function McpDynamicArgs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'long-input':
|
case 'long-input': {
|
||||||
|
const config = createParamConfig(paramName, paramSchema, 'long-input')
|
||||||
return (
|
return (
|
||||||
<McpTextareaWithTags
|
<LongInput
|
||||||
key={`${paramName}-long`}
|
key={`${paramName}-long`}
|
||||||
|
blockId={blockId}
|
||||||
|
subBlockId={`_mcp_${paramName}`}
|
||||||
|
config={config}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
rows={4}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(newValue) => updateParameter(paramName, newValue)}
|
onChange={(newValue) => updateParameter(paramName, newValue)}
|
||||||
placeholder={
|
isPreview={isPreview}
|
||||||
paramSchema.type === 'array'
|
|
||||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
|
||||||
: paramSchema.description ||
|
|
||||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
blockId={blockId}
|
|
||||||
accessiblePrefixes={accessiblePrefixes}
|
|
||||||
rows={4}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const isPassword =
|
const isPassword =
|
||||||
@@ -485,10 +240,16 @@ export function McpDynamicArgs({
|
|||||||
paramName.toLowerCase().includes('password') ||
|
paramName.toLowerCase().includes('password') ||
|
||||||
paramName.toLowerCase().includes('token')
|
paramName.toLowerCase().includes('token')
|
||||||
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
||||||
|
const config = createParamConfig(paramName, paramSchema, 'short-input')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<McpInputWithTags
|
<ShortInput
|
||||||
key={`${paramName}-short`}
|
key={`${paramName}-short`}
|
||||||
|
blockId={blockId}
|
||||||
|
subBlockId={`_mcp_${paramName}`}
|
||||||
|
config={config}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
password={isPassword}
|
||||||
value={value?.toString() || ''}
|
value={value?.toString() || ''}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
let processedValue: any = newValue
|
let processedValue: any = newValue
|
||||||
@@ -506,16 +267,8 @@ export function McpDynamicArgs({
|
|||||||
}
|
}
|
||||||
updateParameter(paramName, processedValue)
|
updateParameter(paramName, processedValue)
|
||||||
}}
|
}}
|
||||||
placeholder={
|
isPreview={isPreview}
|
||||||
paramSchema.type === 'array'
|
|
||||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
|
||||||
: paramSchema.description ||
|
|
||||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isPassword={isPassword}
|
|
||||||
blockId={blockId}
|
|
||||||
accessiblePrefixes={accessiblePrefixes}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -578,26 +331,40 @@ export function McpDynamicArgs({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<div className='space-y-4'>
|
<div>
|
||||||
{toolSchema.properties &&
|
{toolSchema.properties &&
|
||||||
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
|
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
|
||||||
const inputType = getInputType(paramSchema as any)
|
const inputType = getInputType(paramSchema as any)
|
||||||
const showLabel = inputType !== 'switch'
|
const showLabel = inputType !== 'switch'
|
||||||
|
const showDivider = index < entries.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={paramName} className='space-y-2'>
|
<div key={paramName} className='subblock-row'>
|
||||||
{showLabel && (
|
<div className='subblock-content flex flex-col gap-[10px]'>
|
||||||
<Label
|
{showLabel && (
|
||||||
className={cn(
|
<Label
|
||||||
'font-medium text-sm',
|
className={cn(
|
||||||
toolSchema.required?.includes(paramName) &&
|
'font-medium text-sm',
|
||||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
toolSchema.required?.includes(paramName) &&
|
||||||
)}
|
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||||
>
|
)}
|
||||||
{formatParameterLabel(paramName)}
|
>
|
||||||
</Label>
|
{formatParameterLabel(paramName)}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
{renderParameterInput(paramName, paramSchema as any)}
|
||||||
|
</div>
|
||||||
|
{showDivider && (
|
||||||
|
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||||
|
<div
|
||||||
|
className='h-[1.25px]'
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderParameterInput(paramName, paramSchema as any)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function OTPVerificationEmail({
|
|||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
|
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
|
||||||
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
||||||
|
|
||||||
<Section style={baseStyles.codeContainer}>
|
<Section style={baseStyles.codeContainer}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
|
|||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Reset your ${brand.name} password`}>
|
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
|
||||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
A password reset was requested for your {brand.name} account. Click below to set a new
|
A password reset was requested for your {brand.name} account. Click below to set a new
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
|||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Welcome to ${brand.name}`}>
|
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hey ${userName},` : 'Hey,'}
|
{userName ? `Hey ${userName},` : 'Hey,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
|
|||||||
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
|
|||||||
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
|
<EmailLayout
|
||||||
|
preview={`Your Enterprise Plan is now active on ${brand.name}`}
|
||||||
|
showUnsubscribe={false}
|
||||||
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
|
|||||||
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PaymentFailedEmail({
|
|||||||
const previewText = `${brand.name}: Payment Failed - Action Required`
|
const previewText = `${brand.name}: Payment Failed - Action Required`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
|
|||||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function UsageThresholdEmail({
|
|||||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
|
|||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
|
<EmailLayout
|
||||||
|
preview={`Your application to ${brand.name} has been received`}
|
||||||
|
showUnsubscribe={false}
|
||||||
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
We've received your application for <strong>{position}</strong>. Our team reviews every
|
We've received your application for <strong>{position}</strong>. Our team reviews every
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
|
|||||||
submittedDate = new Date(),
|
submittedDate = new Date(),
|
||||||
}: CareersSubmissionEmailProps) {
|
}: CareersSubmissionEmailProps) {
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
|
<EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...baseStyles.paragraph,
|
...baseStyles.paragraph,
|
||||||
|
|||||||
@@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
|
||||||
interface UnsubscribeOptions {
|
|
||||||
unsubscribeToken?: string
|
|
||||||
email?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailFooterProps {
|
interface EmailFooterProps {
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
unsubscribe?: UnsubscribeOptions
|
|
||||||
messageId?: string
|
messageId?: string
|
||||||
|
/**
|
||||||
|
* Whether to show unsubscribe link. Defaults to true.
|
||||||
|
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||||
|
*/
|
||||||
|
showUnsubscribe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email footer component styled to match Stripe's email design.
|
* Email footer component styled to match Stripe's email design.
|
||||||
* Sits in the gray area below the main white card.
|
* Sits in the gray area below the main white card.
|
||||||
|
*
|
||||||
|
* For non-transactional emails, the unsubscribe link uses placeholders
|
||||||
|
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
|
||||||
|
* by the mailer when sending.
|
||||||
*/
|
*/
|
||||||
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
|
export function EmailFooter({
|
||||||
|
baseUrl = getBaseUrl(),
|
||||||
|
messageId,
|
||||||
|
showUnsubscribe = true,
|
||||||
|
}: EmailFooterProps) {
|
||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
|
|
||||||
const footerLinkStyle = {
|
const footerLinkStyle = {
|
||||||
@@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
|
|||||||
•{' '}
|
•{' '}
|
||||||
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</a>{' '}
|
|
||||||
•{' '}
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
unsubscribe?.unsubscribeToken && unsubscribe?.email
|
|
||||||
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
|
|
||||||
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
|
|
||||||
}
|
|
||||||
style={footerLinkStyle}
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
>
|
|
||||||
Unsubscribe
|
|
||||||
</a>
|
</a>
|
||||||
|
{showUnsubscribe && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
•{' '}
|
||||||
|
<a
|
||||||
|
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
|
||||||
|
style={footerLinkStyle}
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ interface EmailLayoutProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
/** Optional: hide footer for internal emails */
|
/** Optional: hide footer for internal emails */
|
||||||
hideFooter?: boolean
|
hideFooter?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to show unsubscribe link in footer.
|
||||||
|
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||||
|
*/
|
||||||
|
showUnsubscribe: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared email layout wrapper providing consistent structure.
|
* Shared email layout wrapper providing consistent structure.
|
||||||
* Includes Html, Head, Body, Container with logo header, and Footer.
|
* Includes Html, Head, Body, Container with logo header, and Footer.
|
||||||
*/
|
*/
|
||||||
export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) {
|
export function EmailLayout({
|
||||||
|
preview,
|
||||||
|
children,
|
||||||
|
hideFooter = false,
|
||||||
|
showUnsubscribe,
|
||||||
|
}: EmailLayoutProps) {
|
||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
@@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* Footer in gray section */}
|
{/* Footer in gray section */}
|
||||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
|
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function BatchInvitationEmail({
|
|||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
||||||
|
showUnsubscribe={false}
|
||||||
>
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export function InvitationEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
|
<EmailLayout
|
||||||
|
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
|
||||||
|
showUnsubscribe={false}
|
||||||
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
|
|||||||
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
|
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}>
|
<EmailLayout
|
||||||
|
preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}
|
||||||
|
showUnsubscribe={false}
|
||||||
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
|
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
|
|||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
|
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
|
||||||
|
showUnsubscribe={false}
|
||||||
>
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
|
|||||||
: 'Your workflow completed successfully.'
|
: 'Your workflow completed successfully.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>{message}</Text>
|
<Text style={baseStyles.paragraph}>{message}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
|
|||||||
const typeLabel = getTypeLabel(type)
|
const typeLabel = getTypeLabel(type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}>
|
<EmailLayout
|
||||||
|
preview={`Your ${typeLabel.toLowerCase()} has been received`}
|
||||||
|
showUnsubscribe={false}
|
||||||
|
>
|
||||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
||||||
|
|||||||
@@ -152,15 +152,20 @@ function addUnsubscribeData(
|
|||||||
): UnsubscribeData {
|
): UnsubscribeData {
|
||||||
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}`
|
const encodedEmail = encodeURIComponent(recipientEmail)
|
||||||
|
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||||
},
|
},
|
||||||
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
html: html
|
||||||
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||||
|
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||||
|
text: text
|
||||||
|
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||||
|
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
|||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.html) emailData.html = email.html
|
|
||||||
if (email.text) emailData.text = email.text
|
|
||||||
|
|
||||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
||||||
emailData.headers = unsubData.headers
|
emailData.headers = unsubData.headers
|
||||||
if (unsubData.html) emailData.html = unsubData.html
|
if (unsubData.html) emailData.html = unsubData.html
|
||||||
if (unsubData.text) emailData.text = unsubData.text
|
if (unsubData.text) emailData.text = unsubData.text
|
||||||
|
} else {
|
||||||
|
if (email.html) emailData.html = email.html
|
||||||
|
if (email.text) emailData.text = email.text
|
||||||
}
|
}
|
||||||
|
|
||||||
batchEmails.push(emailData)
|
batchEmails.push(emailData)
|
||||||
|
|||||||
@@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
|
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
|
||||||
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
|
|
||||||
const salt = 'abc123'
|
const salt = 'abc123'
|
||||||
const secret = 'test-secret-key'
|
const secret = 'test-secret-key'
|
||||||
const { createHash } = require('crypto')
|
const { createHash } = require('crypto')
|
||||||
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
|
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
|
||||||
const legacyToken = `${salt}:${hash}`
|
const legacyToken = `${salt}:${hash}`
|
||||||
|
|
||||||
// This should return valid since we're using the actual legacy format properly
|
|
||||||
const result = verifyUnsubscribeToken(testEmail, legacyToken)
|
const result = verifyUnsubscribeToken(testEmail, legacyToken)
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.emailType).toBe('marketing') // Should default to marketing for legacy tokens
|
expect(result.emailType).toBe('marketing')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should reject malformed tokens', () => {
|
it.concurrent('should reject malformed tokens', () => {
|
||||||
@@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
it('should update email preferences for existing user', async () => {
|
it('should update email preferences for existing user', async () => {
|
||||||
const userId = 'user-123'
|
const userId = 'user-123'
|
||||||
|
|
||||||
// Mock finding the user
|
|
||||||
mockDb.select.mockReturnValueOnce({
|
mockDb.select.mockReturnValueOnce({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
@@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock getting existing settings
|
|
||||||
mockDb.select.mockReturnValueOnce({
|
mockDb.select.mockReturnValueOnce({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
@@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock insert with upsert
|
|
||||||
mockDb.insert.mockReturnValue({
|
mockDb.insert.mockReturnValue({
|
||||||
values: vi.fn().mockReturnValue({
|
values: vi.fn().mockReturnValue({
|
||||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
|
|
||||||
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||||
|
|
||||||
// Verify that the merged preferences are passed
|
|
||||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
emailPreferences: {
|
emailPreferences: {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function verifyUnsubscribeToken(
|
|||||||
const parts = token.split(':')
|
const parts = token.split(':')
|
||||||
if (parts.length < 2) return { valid: false }
|
if (parts.length < 2) return { valid: false }
|
||||||
|
|
||||||
// Handle legacy tokens (without email type)
|
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const [salt, expectedHash] = parts
|
const [salt, expectedHash] = parts
|
||||||
const hash = createHash('sha256')
|
const hash = createHash('sha256')
|
||||||
@@ -48,7 +47,6 @@ export function verifyUnsubscribeToken(
|
|||||||
return { valid: hash === expectedHash, emailType: 'marketing' }
|
return { valid: hash === expectedHash, emailType: 'marketing' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new tokens (with email type)
|
|
||||||
const [salt, expectedHash, emailType] = parts
|
const [salt, expectedHash, emailType] = parts
|
||||||
if (!salt || !expectedHash || !emailType) return { valid: false }
|
if (!salt || !expectedHash || !emailType) return { valid: false }
|
||||||
|
|
||||||
@@ -101,7 +99,6 @@ export async function updateEmailPreferences(
|
|||||||
preferences: EmailPreferences
|
preferences: EmailPreferences
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// First, find the user
|
|
||||||
const userResult = await db
|
const userResult = await db
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
.from(user)
|
.from(user)
|
||||||
@@ -115,7 +112,6 @@ export async function updateEmailPreferences(
|
|||||||
|
|
||||||
const userId = userResult[0].id
|
const userId = userResult[0].id
|
||||||
|
|
||||||
// Get existing email preferences
|
|
||||||
const existingSettings = await db
|
const existingSettings = await db
|
||||||
.select({ emailPreferences: settings.emailPreferences })
|
.select({ emailPreferences: settings.emailPreferences })
|
||||||
.from(settings)
|
.from(settings)
|
||||||
@@ -127,13 +123,11 @@ export async function updateEmailPreferences(
|
|||||||
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
|
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge email preferences
|
|
||||||
const updatedEmailPreferences = {
|
const updatedEmailPreferences = {
|
||||||
...currentEmailPreferences,
|
...currentEmailPreferences,
|
||||||
...preferences,
|
...preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert settings
|
|
||||||
await db
|
await db
|
||||||
.insert(settings)
|
.insert(settings)
|
||||||
.values({
|
.values({
|
||||||
@@ -168,10 +162,8 @@ export async function isUnsubscribed(
|
|||||||
const preferences = await getEmailPreferences(email)
|
const preferences = await getEmailPreferences(email)
|
||||||
if (!preferences) return false
|
if (!preferences) return false
|
||||||
|
|
||||||
// Check unsubscribe all first
|
|
||||||
if (preferences.unsubscribeAll) return true
|
if (preferences.unsubscribeAll) return true
|
||||||
|
|
||||||
// Check specific type
|
|
||||||
switch (emailType) {
|
switch (emailType) {
|
||||||
case 'marketing':
|
case 'marketing':
|
||||||
return preferences.unsubscribeMarketing || false
|
return preferences.unsubscribeMarketing || false
|
||||||
|
|||||||
@@ -1736,8 +1736,13 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
done: (_data, context) => {
|
done: (_data, context) => {
|
||||||
|
logger.info('[SSE] DONE EVENT RECEIVED', {
|
||||||
|
doneEventCount: context.doneEventCount,
|
||||||
|
data: _data,
|
||||||
|
})
|
||||||
context.doneEventCount++
|
context.doneEventCount++
|
||||||
if (context.doneEventCount >= 1) {
|
if (context.doneEventCount >= 1) {
|
||||||
|
logger.info('[SSE] Setting streamComplete = true, stream will terminate')
|
||||||
context.streamComplete = true
|
context.streamComplete = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2542,6 +2547,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
set({
|
set({
|
||||||
chats: [],
|
chats: [],
|
||||||
isLoadingChats: false,
|
isLoadingChats: false,
|
||||||
|
chatsLoadedForWorkflow: workflowId,
|
||||||
error: error instanceof Error ? error.message : 'Failed to load chats',
|
error: error instanceof Error ? error.message : 'Failed to load chats',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,12 +163,13 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
try {
|
try {
|
||||||
const errorMessage = String(newEntry.error)
|
const errorMessage = String(newEntry.error)
|
||||||
const blockName = newEntry.blockName || 'Unknown Block'
|
const blockName = newEntry.blockName || 'Unknown Block'
|
||||||
|
const displayMessage = `${blockName}: ${errorMessage}`
|
||||||
|
|
||||||
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
||||||
|
|
||||||
useNotificationStore.getState().addNotification({
|
useNotificationStore.getState().addNotification({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: errorMessage,
|
message: displayMessage,
|
||||||
workflowId: entry.workflowId,
|
workflowId: entry.workflowId,
|
||||||
action: {
|
action: {
|
||||||
type: 'copilot',
|
type: 'copilot',
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
||||||
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
||||||
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
||||||
'The webhook will be automatically created in Calendly when you save this trigger.',
|
'The webhook will be automatically created in Calendly when you deploy the workflow.',
|
||||||
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
|
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
||||||
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
||||||
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
||||||
'The webhook will be automatically created in Calendly when you save this trigger.',
|
'The webhook will be automatically created in Calendly when you deploy the workflow.',
|
||||||
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
|
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
||||||
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
||||||
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
||||||
'The webhook will be automatically created in Calendly when you save this trigger.',
|
'The webhook will be automatically created in Calendly when you deploy the workflow.',
|
||||||
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
|
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const calendlyWebhookTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
||||||
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
|
||||||
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
|
||||||
'The webhook will be automatically created in Calendly when you save this trigger.',
|
'The webhook will be automatically created in Calendly when you deploy the workflow.',
|
||||||
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
|
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
|
|||||||
'Click "Create Destination" to save',
|
'Click "Create Destination" to save',
|
||||||
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
|
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
|
||||||
'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above',
|
'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above',
|
||||||
'Click "Save" to activate your webhook trigger',
|
'Deploy your workflow to activate the webhook trigger',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
(instruction, index) =>
|
(instruction, index) =>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
|
|||||||
defaultValue: [
|
defaultValue: [
|
||||||
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
|
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
|
||||||
'Enter your Bot Token above.',
|
'Enter your Bot Token above.',
|
||||||
'Save settings and any message sent to your bot will trigger the workflow.',
|
'Any message sent to your bot will trigger the workflow once deployed.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
(instruction, index) =>
|
(instruction, index) =>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
|
|||||||
'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)',
|
'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)',
|
||||||
'Fill in the form above with your Form ID and Personal Access Token',
|
'Fill in the form above with your Form ID and Personal Access Token',
|
||||||
'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret',
|
'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret',
|
||||||
'Click "Save" above - Sim will automatically register the webhook with Typeform',
|
'Sim will automatically register the webhook with Typeform when you deploy the workflow',
|
||||||
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
|
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
Reference in New Issue
Block a user