mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
4 Commits
feat/api
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f444947056 | ||
|
|
bf616f8422 | ||
|
|
4db6bf272a | ||
|
|
aa43c3f732 |
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>) => {}
|
||||||
|
```
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user