mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
4 Commits
feat/super
...
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 { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export { CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export * from './restore-checkpoint-modal'
|
||||
export * from './smooth-streaming'
|
||||
export * from './thinking-block'
|
||||
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 { 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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './thinking-block'
|
||||
@@ -3,15 +3,20 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return text
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './usage-limit-actions'
|
||||
@@ -9,18 +9,22 @@ import {
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
CheckpointDiscardModal,
|
||||
FileAttachmentDisplay,
|
||||
RestoreCheckpointModal,
|
||||
SmoothStreamingText,
|
||||
StreamingIndicator,
|
||||
ThinkingBlock,
|
||||
UsageLimitActions,
|
||||
} 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 {
|
||||
useCheckpointManagement,
|
||||
useMessageContentAnalysis,
|
||||
useMessageEditing,
|
||||
} 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 { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -179,6 +183,32 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [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
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
@@ -191,6 +221,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
// Analyze message content for visibility (used for assistant messages)
|
||||
const { hasVisibleContent } = useMessageContentAnalysis({ message })
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
// No entrance animations to prevent layout shift
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
@@ -290,40 +323,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||
{showCheckpointDiscardModal && (
|
||||
<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={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>
|
||||
<CheckpointDiscardModal
|
||||
isProcessingDiscard={isProcessingDiscard}
|
||||
onCancel={handleCancelCheckpointDiscard}
|
||||
onRevert={handleContinueAndRevert}
|
||||
onContinue={handleContinueWithoutRevert}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -348,46 +353,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
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'}`}
|
||||
>
|
||||
{(() => {
|
||||
const text = message.content || ''
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
: []
|
||||
|
||||
// Build tokens with their prefixes (@ for mentions, / for commands)
|
||||
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
|
||||
})()}
|
||||
{buildMentionHighlightNodes(
|
||||
message.content || '',
|
||||
message.contexts || [],
|
||||
(token, key) => (
|
||||
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gradient fade when truncated - applies to entire message box */}
|
||||
@@ -439,50 +413,16 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
{/* Inline Restore Checkpoint Confirmation */}
|
||||
{showRestoreConfirmation && (
|
||||
<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={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>
|
||||
<RestoreCheckpointModal
|
||||
isReverting={isReverting}
|
||||
onCancel={handleCancelRevert}
|
||||
onConfirm={handleConfirmRevert}
|
||||
/>
|
||||
)}
|
||||
</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) {
|
||||
return (
|
||||
<div
|
||||
@@ -534,6 +474,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
selectedOptionKey={selectedOptionKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||
export { useMessageContentAnalysis } from './use-message-content-analysis'
|
||||
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 { createLogger } from '@sim/logger'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('useMessageEditing')
|
||||
|
||||
/**
|
||||
* Ref interface for UserInput component
|
||||
*/
|
||||
interface UserInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Message truncation height in pixels
|
||||
*/
|
||||
@@ -32,8 +39,8 @@ interface UseMessageEditingProps {
|
||||
setShowCheckpointDiscardModal: (show: boolean) => void
|
||||
pendingEditRef: React.MutableRefObject<{
|
||||
message: string
|
||||
fileAttachments?: any[]
|
||||
contexts?: any[]
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
} | null>
|
||||
/**
|
||||
* When true, disables the internal document click-outside handler.
|
||||
@@ -69,7 +76,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
|
||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||
const userInputRef = useRef<any>(null)
|
||||
const userInputRef = useRef<UserInputRef>(null)
|
||||
|
||||
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
|
||||
*/
|
||||
const performEdit = useCallback(
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
const currentMessages = messages
|
||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||
|
||||
@@ -134,7 +145,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
...message,
|
||||
content: editedMessage,
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
contexts: contexts || message.contexts,
|
||||
}
|
||||
|
||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||
@@ -153,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
timestamp: m.timestamp,
|
||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||
...(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, {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
contexts: contexts || message.contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
@@ -178,7 +189,11 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
* Checks for checkpoints and shows confirmation if needed
|
||||
*/
|
||||
const handleSubmitEdit = useCallback(
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
if (!editedMessage.trim()) return
|
||||
|
||||
if (isSendingMessage) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './copilot-message'
|
||||
@@ -1,7 +1,8 @@
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
export * from './welcome/welcome'
|
||||
export * from './chat-history-skeleton'
|
||||
export * from './copilot-message'
|
||||
export * from './plan-mode-section'
|
||||
export * from './queued-messages'
|
||||
export * from './todo-list'
|
||||
export * from './tool-call'
|
||||
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 { Trash } from '@/components/emcn/icons/trash'
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} 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 { 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'
|
||||
@@ -244,6 +244,7 @@ export function OptionsSelector({
|
||||
disabled = false,
|
||||
enableKeyboardNav = false,
|
||||
streaming = false,
|
||||
selectedOptionKey = null,
|
||||
}: {
|
||||
options: Record<string, OptionItem>
|
||||
onSelect: (optionKey: string, optionText: string) => void
|
||||
@@ -252,6 +253,8 @@ export function OptionsSelector({
|
||||
enableKeyboardNav?: boolean
|
||||
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
||||
streaming?: boolean
|
||||
/** Pre-selected option key (for restoring selection from history) */
|
||||
selectedOptionKey?: string | null
|
||||
}) {
|
||||
const isInteractionDisabled = disabled || streaming
|
||||
const sortedOptions = useMemo(() => {
|
||||
@@ -270,7 +273,7 @@ export function OptionsSelector({
|
||||
}, [options])
|
||||
|
||||
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 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 { ContextPills } from './context-pills/context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
||||
export { AttachedFilesDisplay } from './attached-files-display'
|
||||
export { BottomControls } from './bottom-controls'
|
||||
export { ContextPills } from './context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu'
|
||||
export { ModeSelector } from './mode-selector'
|
||||
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 { useMentionKeyboard } from './use-mention-keyboard'
|
||||
export { useMentionMenu } from './use-mention-menu'
|
||||
export { useMentionSystem } from './use-mention-system'
|
||||
export { useMentionTokens } from './use-mention-tokens'
|
||||
export { useTextareaAutoResize } from './use-textarea-auto-resize'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
escapeRegex,
|
||||
filterOutContext,
|
||||
isContextAlreadySelected,
|
||||
} 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) {
|
||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||
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)
|
||||
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,
|
||||
} from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
|
||||
import { AtSign } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Badge, Button, Textarea } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
AttachedFilesDisplay,
|
||||
BottomControls,
|
||||
ContextPills,
|
||||
type MentionFolderNav,
|
||||
MentionMenu,
|
||||
ModelSelector,
|
||||
ModeSelector,
|
||||
type SlashFolderNav,
|
||||
SlashMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
useTextareaAutoResize,
|
||||
} 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 {
|
||||
computeMentionHighlightRanges,
|
||||
extractContextTokens,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -306,7 +310,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
size: f.size,
|
||||
}))
|
||||
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
|
||||
|
||||
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
||||
if (shouldClearInput) {
|
||||
@@ -657,7 +661,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: string) => {
|
||||
setSelectedModel(model as any)
|
||||
setSelectedModel(model as CopilotModelId)
|
||||
},
|
||||
[setSelectedModel]
|
||||
)
|
||||
@@ -677,15 +681,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||
const tokens = extractContextTokens(contexts)
|
||||
const ranges = computeMentionHighlightRanges(message, tokens)
|
||||
|
||||
if (ranges.length === 0) {
|
||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (let i = 0; i < ranges.length; 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>)
|
||||
}
|
||||
|
||||
const mentionText = message.slice(range.start, range.end)
|
||||
elements.push(
|
||||
<span
|
||||
key={`mention-${i}-${range.start}-${range.end}`}
|
||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||
>
|
||||
{mentionText}
|
||||
{range.token}
|
||||
</span>
|
||||
)
|
||||
lastIndex = range.end
|
||||
@@ -713,7 +718,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
||||
}, [message, contextManagement.selectedContexts])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -855,87 +860,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||
<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={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>
|
||||
<BottomControls
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
isNearTop={isNearTop}
|
||||
disabled={disabled}
|
||||
hideModeSelector={hideModeSelector}
|
||||
canSubmit={canSubmit}
|
||||
isLoading={isLoading}
|
||||
isAborting={isAborting}
|
||||
showAbortButton={Boolean(showAbortButton)}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
onAbort={handleAbort}
|
||||
onFileSelect={fileAttachments.handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||
<input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
FOLDER_CONFIGS,
|
||||
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 { 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.
|
||||
* 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 { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
ChatHistorySkeleton,
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
useTodoManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
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 cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||
|
||||
// Derived state - editing when there's an editingMessageId
|
||||
const isEditingMessage = editingMessageId !== null
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const {
|
||||
@@ -106,9 +110,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
areChatsFresh,
|
||||
workflowId: copilotWorkflowId,
|
||||
setPlanTodos,
|
||||
closePlanTodos,
|
||||
clearPlanArtifact,
|
||||
savePlanArtifact,
|
||||
setSelectedModel,
|
||||
loadAutoAllowedTools,
|
||||
} = useCopilotStore()
|
||||
|
||||
@@ -292,6 +296,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}
|
||||
}, [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
|
||||
* @param query - The message text to send
|
||||
@@ -299,13 +312,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
* @param contexts - Optional context references
|
||||
*/
|
||||
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
|
||||
if (!query || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
const store = useCopilotStore.getState()
|
||||
store.setPlanTodos([])
|
||||
setPlanTodos([])
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -319,7 +331,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
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(
|
||||
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
||||
setEditingMessageId(isEditing ? messageId : null)
|
||||
setIsEditingMessage(isEditing)
|
||||
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
||||
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
||||
},
|
||||
@@ -375,24 +386,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[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 (
|
||||
<>
|
||||
<div
|
||||
@@ -588,11 +581,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<TodoList
|
||||
todos={planTodos}
|
||||
collapsed={todosCollapsed}
|
||||
onClose={() => {
|
||||
const store = useCopilotStore.getState()
|
||||
store.closePlanTodos?.()
|
||||
useCopilotStore.setState({ planTodos: [] })
|
||||
}}
|
||||
onClose={handleClosePlanTodos}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -80,6 +80,25 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
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
|
||||
if (
|
||||
activeWorkflowId &&
|
||||
|
||||
@@ -1736,8 +1736,13 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
}
|
||||
},
|
||||
done: (_data, context) => {
|
||||
logger.info('[SSE] DONE EVENT RECEIVED', {
|
||||
doneEventCount: context.doneEventCount,
|
||||
data: _data,
|
||||
})
|
||||
context.doneEventCount++
|
||||
if (context.doneEventCount >= 1) {
|
||||
logger.info('[SSE] Setting streamComplete = true, stream will terminate')
|
||||
context.streamComplete = true
|
||||
}
|
||||
},
|
||||
@@ -2542,6 +2547,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
set({
|
||||
chats: [],
|
||||
isLoadingChats: false,
|
||||
chatsLoadedForWorkflow: workflowId,
|
||||
error: error instanceof Error ? error.message : 'Failed to load chats',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user