fix(copilot): ui/ux (#2891)

* feat(claude): added rules

* fix(copilot): chat loading; refactor(copilot): components, utils, hooks

* fix(copilot): options selection strikethrough

* fix(copilot): options render inside thinking

* fix(copilot): checkpoints, user-input; improvement(code): colors

* fix(copilot): scrolling, tool-call truncation, thinking ui

* fix(copilot): tool call spacing and shimmer/actions on previous messages

* improvement(copilot): queue

* addressed comments
This commit is contained in:
Emir Karabeg
2026-01-19 23:23:21 -08:00
committed by GitHub
parent ac991d4b54
commit 2daf34386e
75 changed files with 1764 additions and 1040 deletions
+35
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1,21 @@
---
paths:
- "apps/sim/**/*.ts"
- "apps/sim/**/*.tsx"
---
# TypeScript Rules
1. **No `any`** - Use proper types or `unknown` with type guards
2. **Props interface** - Always define for components
3. **Const assertions** - `as const` for constant objects/arrays
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
5. **Type imports** - `import type { X }` for type-only imports
```typescript
// ✗ Bad
const handleClick = (e: any) => {}
// ✓ Good
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
```
+1 -1
View File
@@ -8,7 +8,7 @@ alwaysApply: true
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
## Logging
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
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.
@@ -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,79 @@
import { Button } from '@/components/emcn'
type CheckpointConfirmationVariant = 'restore' | 'discard'
interface CheckpointConfirmationProps {
/** Confirmation variant - 'restore' for reverting, 'discard' for edit with checkpoint options */
variant: CheckpointConfirmationVariant
/** Whether an action is currently processing */
isProcessing: boolean
/** Callback when cancel is clicked */
onCancel: () => void
/** Callback when revert is clicked */
onRevert: () => void
/** Callback when continue is clicked (only for 'discard' variant) */
onContinue?: () => void
}
/**
* Inline confirmation for checkpoint operations
* Supports two variants:
* - 'restore': Simple revert confirmation with warning
* - 'discard': Edit with checkpoint options (revert or continue without revert)
*/
export function CheckpointConfirmation({
variant,
isProcessing,
onCancel,
onRevert,
onContinue,
}: CheckpointConfirmationProps) {
const isRestoreVariant = variant === 'restore'
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)]'>
{isRestoreVariant ? (
<>
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>
</>
) : (
'Continue from a previous message?'
)}
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={onRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessing}
>
{isProcessing ? 'Reverting...' : 'Revert'}
</Button>
{!isRestoreVariant && onContinue && (
<Button
onClick={onContinue}
variant='tertiary'
size='sm'
className='flex-1'
disabled={isProcessing}
>
Continue
</Button>
)}
</div>
</div>
)
}
@@ -1,5 +1,6 @@
export * from './checkpoint-confirmation'
export * from './file-display'
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
export { CopilotMarkdownRenderer } from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'
export * from './usage-limit-actions'
@@ -0,0 +1 @@
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
@@ -1,27 +1,17 @@
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
*/
/** Character animation delay in milliseconds */
const CHARACTER_DELAY = 3
/**
* Props for the StreamingIndicator component
*/
/** Props for the StreamingIndicator component */
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}
/**
* StreamingIndicator shows animated dots during message streaming
* Used as a standalone indicator when no content has arrived yet
*
* @param props - Component props
* @returns Animated loading indicator
*/
/** Shows animated dots during message streaming when no content has arrived */
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
@@ -34,9 +24,7 @@ export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps)
StreamingIndicator.displayName = 'StreamingIndicator'
/**
* Props for the SmoothStreamingText component
*/
/** Props for the SmoothStreamingText component */
interface SmoothStreamingTextProps {
/** Content to display with streaming animation */
content: string
@@ -44,20 +32,12 @@ interface SmoothStreamingTextProps {
isStreaming: boolean
}
/**
* SmoothStreamingText component displays text with character-by-character animation
* Creates a smooth streaming effect for AI responses
*
* @param props - Component props
* @returns Streaming text with smooth animation
*/
/** Displays text with character-by-character animation for smooth streaming */
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
// Initialize with full content when not streaming to avoid flash on page load
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// Initialize index based on streaming state
const indexRef = useRef(isStreaming ? 0 : content.length)
const isAnimatingRef = useRef(false)
@@ -95,7 +75,6 @@ export const SmoothStreamingText = memo(
}
}
} else {
// Streaming ended - show full content immediately
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
@@ -119,7 +98,6 @@ export const SmoothStreamingText = memo(
)
},
(prevProps, nextProps) => {
// Prevent re-renders during streaming unless content actually changed
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
)
@@ -3,66 +3,45 @@
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.
*/
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.replace(/<options>[\s\S]*?<\/options>/gi, '')
.replace(/<options>[\s\S]*$/gi, '')
.replace(/<plan>[\s\S]*?<\/plan>/gi, '')
.replace(/<plan>[\s\S]*$/gi, '')
.trim()
}
/**
* Max height for thinking content before internal scrolling kicks in
*/
const THINKING_MAX_HEIGHT = 150
/**
* Height threshold before gradient fade kicks in
*/
const GRADIENT_THRESHOLD = 100
/**
* Interval for auto-scroll during streaming (ms)
*/
/** Interval for auto-scroll during streaming (ms) */
const SCROLL_INTERVAL = 50
/**
* Timer update interval in milliseconds
*/
/** Timer update interval in milliseconds */
const TIMER_UPDATE_INTERVAL = 100
/**
* Thinking text streaming - much faster than main text
* Essentially instant with minimal delay
*/
/** Thinking text streaming delay - faster than main text */
const THINKING_DELAY = 0.5
const THINKING_CHARS_PER_FRAME = 3
/**
* Props for the SmoothThinkingText component
*/
/** Props for the SmoothThinkingText component */
interface SmoothThinkingTextProps {
content: string
isStreaming: boolean
}
/**
* SmoothThinkingText renders thinking content with fast streaming animation
* Uses gradient fade at top when content is tall enough
* Renders thinking content with fast streaming animation.
*/
const SmoothThinkingText = memo(
({ content, isStreaming }: SmoothThinkingTextProps) => {
// Initialize with full content when not streaming to avoid flash on page load
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const [showGradient, setShowGradient] = useState(false)
const contentRef = useRef(content)
const textRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
// Initialize index based on streaming state
const indexRef = useRef(isStreaming ? 0 : content.length)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)
@@ -88,7 +67,6 @@ const SmoothThinkingText = memo(
if (elapsed >= THINKING_DELAY) {
if (currentIndex < currentContent.length) {
// Reveal multiple characters per frame for faster streaming
const newIndex = Math.min(
currentIndex + THINKING_CHARS_PER_FRAME,
currentContent.length
@@ -110,7 +88,6 @@ const SmoothThinkingText = memo(
rafRef.current = requestAnimationFrame(animateText)
}
} else {
// Streaming ended - show full content immediately
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
@@ -127,30 +104,10 @@ const SmoothThinkingText = memo(
}
}, [content, isStreaming])
// Check if content height exceeds threshold for gradient
useEffect(() => {
if (textRef.current && isStreaming) {
const height = textRef.current.scrollHeight
setShowGradient(height > GRADIENT_THRESHOLD)
} else {
setShowGradient(false)
}
}, [displayedContent, isStreaming])
// Apply vertical gradient fade at the top only when content is tall enough
const gradientStyle =
isStreaming && showGradient
? {
maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
}
: undefined
return (
<div
ref={textRef}
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
style={gradientStyle}
>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
@@ -165,9 +122,7 @@ const SmoothThinkingText = memo(
SmoothThinkingText.displayName = 'SmoothThinkingText'
/**
* Props for the ThinkingBlock component
*/
/** Props for the ThinkingBlock component */
interface ThinkingBlockProps {
/** Content of the thinking block */
content: string
@@ -182,13 +137,8 @@ interface ThinkingBlockProps {
}
/**
* ThinkingBlock component displays AI reasoning/thinking process
* Shows collapsible content with duration timer
* Auto-expands during streaming and collapses when complete
* Auto-collapses when a tool call or other content comes in after it
*
* @param props - Component props
* @returns Thinking block with expandable content and timer
* Displays AI reasoning/thinking process with collapsible content and duration timer.
* Auto-expands during streaming and collapses when complete.
*/
export function ThinkingBlock({
content,
@@ -197,7 +147,6 @@ export function ThinkingBlock({
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
// Strip thinking tags from content on render to handle persisted messages
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
const [isExpanded, setIsExpanded] = useState(false)
@@ -209,12 +158,8 @@ export function ThinkingBlock({
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
/**
* Auto-expands block when streaming with content
* Auto-collapses when streaming ends OR when following content arrives
*/
/** Auto-expands during streaming, auto-collapses when streaming ends or following content arrives */
useEffect(() => {
// Collapse if streaming ended, there's following content, or special tags arrived
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
setIsExpanded(false)
userCollapsedRef.current = false
@@ -227,7 +172,6 @@ export function ThinkingBlock({
}
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
// Reset start time when streaming begins
useEffect(() => {
if (isStreaming && !hasFollowingContent) {
startTimeRef.current = Date.now()
@@ -236,9 +180,7 @@ export function ThinkingBlock({
}
}, [isStreaming, hasFollowingContent])
// Update duration timer during streaming (stop when following content arrives)
useEffect(() => {
// Stop timer if not streaming or if there's following content (thinking is done)
if (!isStreaming || hasFollowingContent) return
const interval = setInterval(() => {
@@ -248,7 +190,6 @@ export function ThinkingBlock({
return () => clearInterval(interval)
}, [isStreaming, hasFollowingContent])
// Handle scroll events to detect user scrolling away
useEffect(() => {
const container = scrollContainerRef.current
if (!container || !isExpanded) return
@@ -267,7 +208,6 @@ export function ThinkingBlock({
setUserHasScrolledAway(true)
}
// Re-stick if user scrolls back to bottom with intent
if (userHasScrolledAway && isNearBottom && delta > 10) {
setUserHasScrolledAway(false)
}
@@ -281,7 +221,6 @@ export function ThinkingBlock({
return () => container.removeEventListener('scroll', handleScroll)
}, [isExpanded, userHasScrolledAway])
// Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away
useEffect(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return
@@ -302,20 +241,16 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
/**
* Formats duration in milliseconds to seconds
* Always shows seconds, rounded to nearest whole second, minimum 1s
*/
/** Formats duration in milliseconds to seconds (minimum 1s) */
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = cleanContent.length > 0
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
const durationText = `${label} for ${formatDuration(duration)}`
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
@@ -323,11 +258,9 @@ export function ThinkingBlock({
}
const streamingLabel = getStreamingLabel(label)
// During streaming: show header with shimmer effect + expanded content
if (!isThinkingDone) {
return (
<div>
{/* Define shimmer keyframes */}
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
@@ -396,7 +329,6 @@ export function ThinkingBlock({
)
}
// After done: show collapsible header with duration
return (
<div>
<button
@@ -426,7 +358,6 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{/* Completed thinking text - dimmed with markdown */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={cleanContent} />
</div>
@@ -9,18 +9,20 @@ import {
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
CheckpointConfirmation,
FileAttachmentDisplay,
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,
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'
@@ -68,7 +70,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
// Store state
const {
messageCheckpoints: allMessageCheckpoints,
messages,
@@ -79,23 +80,18 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
isAborting,
} = useCopilotStore()
// Get checkpoints for this message if it's a user message
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
// Check if this is the last user message (for showing abort button)
const isLastUserMessage = useMemo(() => {
if (!isUser) return false
const userMessages = messages.filter((m) => m.role === 'user')
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
}, [isUser, messages, message.id])
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
// Checkpoint management hook
const {
showRestoreConfirmation,
showCheckpointDiscardModal,
@@ -118,7 +114,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
() => cancelEditRef.current?.()
)
// Message editing hook
const {
isEditMode,
isExpanded,
@@ -147,27 +142,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
cancelEditRef.current = handleCancelEdit
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
// Parse out excessive newlines (more than 2 consecutive newlines)
return message.content.replace(/\n{3,}/g, '\n\n')
}, [message.content])
// Parse special tags from message content (options, plan)
// Parse during streaming to show options/plan as they stream in
const parsedTags = useMemo(() => {
if (isUser) return null
// Try message.content first
if (message.content) {
const parsed = parseSpecialTags(message.content)
if (parsed.options || parsed.plan) return parsed
}
// During streaming, check content blocks for options/plan
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
if (message.contentBlocks && message.contentBlocks.length > 0) {
for (const block of message.contentBlocks) {
if (block.type === 'text' && block.content) {
const parsed = parseSpecialTags(block.content)
@@ -176,23 +164,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
}
}
return message.content ? parseSpecialTags(message.content) : null
}, [message.content, message.contentBlocks, isUser, isStreaming])
return null
}, [message.content, message.contentBlocks, isUser])
const selectedOptionKey = useMemo(() => {
if (!parsedTags?.options || isStreaming) return null
const currentIndex = messages.findIndex((m) => m.id === message.id)
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
const nextMessage = messages[currentIndex + 1]
if (!nextMessage || nextMessage.role !== 'user') return null
const nextContent = nextMessage.content?.trim()
if (!nextContent) return null
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)
// Handler for option selection
const handleOptionSelect = useCallback(
(_optionKey: string, optionText: string) => {
// Send the option text as a message
sendMessage(optionText)
},
[sendMessage]
)
// Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const isActivelyStreaming = isLastMessage && isStreaming
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
return null
@@ -202,21 +209,21 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'text') {
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
// Always strip special tags from display (they're rendered separately as options/plan)
const parsed = parseSpecialTags(block.content)
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
// Skip if no content after stripping tags
if (!cleanBlockContent.trim()) return null
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
const shouldUseSmoothing = isActivelyStreaming && isLastTextBlock
const blockKey = `text-${index}-${block.timestamp || index}`
return (
<div key={blockKey} className='w-full max-w-full'>
{shouldUseSmoothing ? (
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
<SmoothStreamingText
content={cleanBlockContent}
isStreaming={isActivelyStreaming}
/>
) : (
<CopilotMarkdownRenderer content={cleanBlockContent} />
)}
@@ -224,9 +231,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'thinking') {
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
// Check if special tags (options, plan) are present - should also close thinking
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
const blockKey = `thinking-${index}-${block.timestamp || index}`
@@ -234,7 +239,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
<div key={blockKey} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreaming}
isStreaming={isActivelyStreaming}
hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}
/>
@@ -246,18 +251,22 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return (
<div key={blockKey}>
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
<ToolCall
toolCallId={block.toolCall.id}
toolCall={block.toolCall}
isCurrentMessage={isLastMessage}
/>
</div>
)
}
return null
})
}, [message.contentBlocks, isStreaming, parsedTags])
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
if (isUser) {
return (
<div
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`w-full max-w-full flex-none overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
{isEditMode ? (
@@ -288,42 +297,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
initialContexts={message.contexts}
/>
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
{/* Inline checkpoint 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>
<CheckpointConfirmation
variant='discard'
isProcessing={isProcessingDiscard}
onCancel={handleCancelCheckpointDiscard}
onRevert={handleContinueAndRevert}
onContinue={handleContinueWithoutRevert}
/>
)}
</div>
) : (
@@ -348,46 +330,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 */}
@@ -437,65 +388,30 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
</div>
)}
{/* Inline Restore Checkpoint Confirmation */}
{/* 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>
<CheckpointConfirmation
variant='restore'
isProcessing={isReverting}
onCancel={handleCancelRevert}
onRevert={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
className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div className='max-w-full space-y-1 px-[2px]'>
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
{isStreaming && (
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
)}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>
@@ -534,6 +450,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
}
streaming={isStreaming || !parsedTags.optionsComplete}
selectedOptionKey={selectedOptionKey}
/>
)}
</div>
@@ -544,50 +461,22 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return null
},
(prevProps, nextProps) => {
// Custom comparison function for better streaming performance
const prevMessage = prevProps.message
const nextMessage = nextProps.message
// If message IDs are different, always re-render
if (prevMessage.id !== nextMessage.id) {
return false
}
if (prevMessage.id !== nextMessage.id) return false
if (prevProps.isStreaming !== nextProps.isStreaming) return false
if (prevProps.isDimmed !== nextProps.isDimmed) return false
if (prevProps.panelWidth !== nextProps.panelWidth) return false
if (prevProps.checkpointCount !== nextProps.checkpointCount) return false
if (prevProps.isLastMessage !== nextProps.isLastMessage) return false
// If streaming state changed, re-render
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false
}
// If dimmed state changed, re-render
if (prevProps.isDimmed !== nextProps.isDimmed) {
return false
}
// If panel width changed, re-render
if (prevProps.panelWidth !== nextProps.panelWidth) {
return false
}
// If checkpoint count changed, re-render
if (prevProps.checkpointCount !== nextProps.checkpointCount) {
return false
}
// If isLastMessage changed, re-render (for options visibility)
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
return false
}
// For streaming messages, check if content actually changed
if (nextProps.isStreaming) {
const prevBlocks = prevMessage.contentBlocks || []
const nextBlocks = nextMessage.contentBlocks || []
if (prevBlocks.length !== nextBlocks.length) {
return false // Content blocks changed
}
if (prevBlocks.length !== nextBlocks.length) return false
// Helper: get last block content by type
const getLastBlockContent = (blocks: any[], type: 'text' | 'thinking'): string | null => {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i]
@@ -598,7 +487,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return null
}
// Re-render if the last text block content changed
const prevLastTextContent = getLastBlockContent(prevBlocks as any[], 'text')
const nextLastTextContent = getLastBlockContent(nextBlocks as any[], 'text')
if (
@@ -609,7 +497,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return false
}
// Re-render if the last thinking block content changed
const prevLastThinkingContent = getLastBlockContent(prevBlocks as any[], 'thinking')
const nextLastThinkingContent = getLastBlockContent(nextBlocks as any[], 'thinking')
if (
@@ -620,24 +507,18 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return false
}
// Check if tool calls changed
const prevToolCalls = prevMessage.toolCalls || []
const nextToolCalls = nextMessage.toolCalls || []
if (prevToolCalls.length !== nextToolCalls.length) {
return false // Tool calls count changed
}
if (prevToolCalls.length !== nextToolCalls.length) return false
for (let i = 0; i < nextToolCalls.length; i++) {
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) {
return false // Tool call state changed
}
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
}
return true
}
// For non-streaming messages, do a deeper comparison including tool call states
if (
prevMessage.content !== nextMessage.content ||
prevMessage.role !== nextMessage.role ||
@@ -647,16 +528,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return false
}
// Check tool call states for non-streaming messages too
const prevToolCalls = prevMessage.toolCalls || []
const nextToolCalls = nextMessage.toolCalls || []
for (let i = 0; i < nextToolCalls.length; i++) {
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) {
return false // Tool call state changed
}
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
}
// Check contentBlocks tool call states
const prevContentBlocks = prevMessage.contentBlocks || []
const nextContentBlocks = nextMessage.contentBlocks || []
for (let i = 0; i < nextContentBlocks.length; i++) {
@@ -667,7 +544,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
nextBlock?.type === 'tool_call' &&
prevBlock.toolCall?.state !== nextBlock.toolCall?.state
) {
return false // ContentBlock tool call state changed
return false
}
}
@@ -15,6 +15,7 @@ const logger = createLogger('useCheckpointManagement')
* @param messageCheckpoints - Checkpoints for this message
* @param onRevertModeChange - Callback for revert mode changes
* @param onEditModeChange - Callback for edit mode changes
* @param onCancelEdit - Callback when edit is cancelled
* @returns Checkpoint management utilities
*/
export function useCheckpointManagement(
@@ -37,17 +38,13 @@ export function useCheckpointManagement(
const { revertToCheckpoint, currentChat } = useCopilotStore()
/**
* Handles initiating checkpoint revert
*/
/** Initiates checkpoint revert confirmation */
const handleRevertToCheckpoint = useCallback(() => {
setShowRestoreConfirmation(true)
onRevertModeChange?.(true)
}, [onRevertModeChange])
/**
* Confirms checkpoint revert and updates state
*/
/** Confirms and executes checkpoint revert */
const handleConfirmRevert = useCallback(async () => {
if (messageCheckpoints.length > 0) {
const latestCheckpoint = messageCheckpoints[0]
@@ -116,18 +113,13 @@ export function useCheckpointManagement(
onRevertModeChange,
])
/**
* Cancels checkpoint revert
*/
/** Cancels checkpoint revert */
const handleCancelRevert = useCallback(() => {
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
}, [onRevertModeChange])
/**
* Handles "Continue and revert" action for checkpoint discard modal
* Reverts to checkpoint then proceeds with pending edit
*/
/** Reverts to checkpoint then proceeds with pending edit */
const handleContinueAndRevert = useCallback(async () => {
setIsProcessingDiscard(true)
try {
@@ -184,9 +176,7 @@ export function useCheckpointManagement(
}
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
/**
* Cancels checkpoint discard and clears pending edit
*/
/** Cancels checkpoint discard and clears pending edit */
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
@@ -194,11 +184,11 @@ export function useCheckpointManagement(
pendingEditRef.current = null
}, [onEditModeChange, onCancelEdit])
/**
* Continues with edit WITHOUT reverting checkpoint
*/
/** Continues with edit without reverting checkpoint */
const handleContinueWithoutRevert = useCallback(async () => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
if (pendingEditRef.current) {
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
@@ -225,43 +215,34 @@ export function useCheckpointManagement(
}
}, [message, messages, onEditModeChange, onCancelEdit])
/**
* Handles keyboard events for restore confirmation (Escape/Enter)
*/
/** Handles keyboard events for confirmation dialogs */
useEffect(() => {
if (!showRestoreConfirmation) return
const isActive = showRestoreConfirmation || showCheckpointDiscardModal
if (!isActive) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === 'Escape') {
handleCancelRevert()
if (showRestoreConfirmation) handleCancelRevert()
else handleCancelCheckpointDiscard()
} else if (event.key === 'Enter') {
event.preventDefault()
handleConfirmRevert()
if (showRestoreConfirmation) handleConfirmRevert()
else handleContinueAndRevert()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [showRestoreConfirmation, handleCancelRevert, handleConfirmRevert])
/**
* Handles keyboard events for checkpoint discard modal (Escape/Enter)
*/
useEffect(() => {
if (!showCheckpointDiscardModal) return
const handleCheckpointDiscardKeyDown = async (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleCancelCheckpointDiscard()
} else if (event.key === 'Enter') {
event.preventDefault()
await handleContinueAndRevert()
}
}
document.addEventListener('keydown', handleCheckpointDiscardKeyDown)
return () => document.removeEventListener('keydown', handleCheckpointDiscardKeyDown)
}, [showCheckpointDiscardModal, handleCancelCheckpointDiscard, handleContinueAndRevert])
}, [
showRestoreConfirmation,
showCheckpointDiscardModal,
handleCancelRevert,
handleConfirmRevert,
handleCancelCheckpointDiscard,
handleContinueAndRevert,
])
return {
// State
@@ -2,24 +2,23 @@
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')
/**
* Message truncation height in pixels
*/
/** Ref interface for UserInput component */
interface UserInputRef {
focus: () => void
}
/** Message truncation height in pixels */
const MESSAGE_TRUNCATION_HEIGHT = 60
/**
* Delay before attaching click-outside listener to avoid immediate trigger
*/
/** Delay before attaching click-outside listener to avoid immediate trigger */
const CLICK_OUTSIDE_DELAY = 100
/**
* Delay before aborting when editing during stream
*/
/** Delay before aborting when editing during stream */
const ABORT_DELAY = 100
interface UseMessageEditingProps {
@@ -32,8 +31,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,13 +68,11 @@ 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()
/**
* Checks if message content needs expansion based on height
*/
/** Checks if message content needs expansion based on height */
useEffect(() => {
if (messageContentRef.current && message.role === 'user') {
const scrollHeight = messageContentRef.current.scrollHeight
@@ -83,9 +80,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
}
}, [message.content, message.role])
/**
* Handles entering edit mode
*/
/** Enters edit mode */
const handleEditMessage = useCallback(() => {
setIsEditMode(true)
setIsExpanded(false)
@@ -97,18 +92,14 @@ export function useMessageEditing(props: UseMessageEditingProps) {
}, 0)
}, [message.content, onEditModeChange])
/**
* Handles canceling edit mode
*/
/** Cancels edit mode */
const handleCancelEdit = useCallback(() => {
setIsEditMode(false)
setEditedContent(message.content)
onEditModeChange?.(false)
}, [message.content, onEditModeChange])
/**
* Handles clicking on message to enter edit mode
*/
/** Handles message click to enter edit mode */
const handleMessageClick = useCallback(() => {
if (needsExpansion && !isExpanded) {
setIsExpanded(true)
@@ -116,12 +107,13 @@ export function useMessageEditing(props: UseMessageEditingProps) {
handleEditMessage()
}, [needsExpansion, isExpanded, handleEditMessage])
/**
* Performs the actual edit operation
* Truncates messages after edited message and resends with same ID
*/
/** Performs the edit operation - truncates messages after edited message and resends */
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 +126,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 +145,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 +156,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,
})
@@ -173,12 +165,13 @@ export function useMessageEditing(props: UseMessageEditingProps) {
[messages, message, currentChat, sendMessage, onEditModeChange]
)
/**
* Handles submitting edited message
* Checks for checkpoints and shows confirmation if needed
*/
/** Submits edited message, checking for checkpoints first */
const handleSubmitEdit = useCallback(
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
if (!editedMessage.trim()) return
if (isSendingMessage) {
@@ -204,9 +197,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
]
)
/**
* Keyboard-only exit (Esc). Click-outside is optionally handled by parent.
*/
/** Keyboard-only exit (Esc) */
useEffect(() => {
if (!isEditMode) return
@@ -222,9 +213,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
}
}, [isEditMode, handleCancelEdit])
/**
* Optional document-level click-outside handler (disabled when parent manages it).
*/
/** Optional document-level click-outside handler */
useEffect(() => {
if (!isEditMode || disableDocumentClickOutside) return
@@ -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'
@@ -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
@@ -31,21 +31,22 @@ export function QueuedMessages() {
if (messageQueue.length === 0) return null
return (
<div className='mx-2 overflow-hidden rounded-t-lg border border-black/[0.08] border-b-0 bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
<div className='mx-[14px] overflow-hidden rounded-t-[4px] border border-[var(--border)] border-b-0 bg-[var(--bg-secondary)]'>
{/* Header */}
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
className='flex w-full items-center justify-between px-[10px] py-[6px] transition-colors hover:bg-[var(--surface-3)]'
>
<div className='flex items-center gap-1.5'>
<div className='flex items-center gap-[6px]'>
{isExpanded ? (
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
) : (
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
)}
<span className='font-medium text-[var(--text-secondary)] text-xs'>
{messageQueue.length} Queued
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Queued</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{messageQueue.length}
</span>
</div>
</button>
@@ -56,30 +57,30 @@ export function QueuedMessages() {
{messageQueue.map((msg) => (
<div
key={msg.id}
className='group flex items-center gap-2 border-black/[0.04] border-t px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
className='group flex items-center gap-[8px] border-[var(--border)] border-t px-[10px] py-[6px] hover:bg-[var(--surface-3)]'
>
{/* Radio indicator */}
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
<div className='flex h-[14px] w-[14px] shrink-0 items-center justify-center'>
<div className='h-[10px] w-[10px] rounded-full border border-[var(--text-tertiary)]/50' />
</div>
{/* Message content */}
<div className='min-w-0 flex-1'>
<p className='truncate text-[var(--text-primary)] text-xs'>{msg.content}</p>
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
</div>
{/* Actions - always visible */}
<div className='flex shrink-0 items-center gap-0.5'>
<div className='flex shrink-0 items-center gap-[4px]'>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleSendNow(msg.id)
}}
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
title='Send now (aborts current stream)'
>
<ArrowUp className='h-3 w-3' />
<ArrowUp className='h-[14px] w-[14px]' />
</button>
<button
type='button'
@@ -87,10 +88,10 @@ export function QueuedMessages() {
e.stopPropagation()
handleRemove(msg.id)
}}
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
title='Remove from queue'
>
<Trash2 className='h-3 w-3' />
<Trash2 className='h-[14px] w-[14px]' />
</button>
</div>
</div>
@@ -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'
@@ -26,27 +26,30 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Plan step can be either a string or an object with title and plan
*/
/** Plan step can be a string or an object with title and optional plan content */
type PlanStep = string | { title: string; plan?: string }
/**
* Option can be either a string or an object with title and description
*/
/** Option can be a string or an object with title and optional description */
type OptionItem = string | { title: string; description?: string }
/** Result of parsing special XML tags from message content */
interface ParsedTags {
/** Parsed plan steps, keyed by step number */
plan?: Record<string, PlanStep>
/** Whether the plan tag is complete (has closing tag) */
planComplete?: boolean
/** Parsed options, keyed by option number */
options?: Record<string, OptionItem>
/** Whether the options tag is complete (has closing tag) */
optionsComplete?: boolean
/** Content with special tags removed */
cleanContent: string
}
/**
* Extract plan steps from plan_respond tool calls in subagent blocks.
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
* Extracts plan steps from plan_respond tool calls in subagent blocks.
* @param blocks - The subagent content blocks to search
* @returns Object containing steps in the format expected by PlanSteps component, and completion status
*/
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
steps: Record<string, PlanStep> | undefined
@@ -54,7 +57,6 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
} {
if (!blocks) return { steps: undefined, isComplete: false }
// Find the plan_respond tool call
const planRespondBlock = blocks.find(
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
)
@@ -63,8 +65,6 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
return { steps: undefined, isComplete: false }
}
// Tool call arguments can be in different places depending on the source
// Also handle nested data.arguments structure from the schema
const tc = planRespondBlock.toolCall as any
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
const stepsArray = args.steps
@@ -73,9 +73,6 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
return { steps: undefined, isComplete: false }
}
// Convert array format to Record<string, PlanStep> format
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
// To: { "1": "...", "2": "..." }
const steps: Record<string, PlanStep> = {}
for (const step of stepsArray) {
if (step.number !== undefined && step.title) {
@@ -83,7 +80,6 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
}
}
// Check if the tool call is complete (not pending/executing)
const isComplete =
planRespondBlock.toolCall.state === ClientToolCallState.success ||
planRespondBlock.toolCall.state === ClientToolCallState.error
@@ -95,8 +91,9 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
}
/**
* Try to parse partial JSON for streaming options.
* Attempts to extract complete key-value pairs from incomplete JSON.
* Parses partial JSON for streaming options, extracting complete key-value pairs from incomplete JSON.
* @param jsonStr - The potentially incomplete JSON string
* @returns Parsed options record or null if no valid options found
*/
function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> | null {
// Try parsing as-is first (might be complete)
@@ -107,8 +104,9 @@ function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> |
}
// Try to extract complete key-value pairs from partial JSON
// Match patterns like "1": "some text" or "1": {"title": "text"}
// Match patterns like "1": "some text" or "1": {"title": "text", "description": "..."}
const result: Record<string, OptionItem> = {}
// Match complete string values: "key": "value"
const stringPattern = /"(\d+)":\s*"([^"]*?)"/g
let match
@@ -116,18 +114,24 @@ function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> |
result[match[1]] = match[2]
}
// Match complete object values: "key": {"title": "value"}
const objectPattern = /"(\d+)":\s*\{[^}]*"title":\s*"([^"]*)"[^}]*\}/g
// Match complete object values with title and optional description
// Pattern matches: "1": {"title": "...", "description": "..."} or "1": {"title": "..."}
const objectPattern =
/"(\d+)":\s*\{\s*"title":\s*"((?:[^"\\]|\\.)*)"\s*(?:,\s*"description":\s*"((?:[^"\\]|\\.)*)")?\s*\}/g
while ((match = objectPattern.exec(jsonStr)) !== null) {
result[match[1]] = { title: match[2] }
const key = match[1]
const title = match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n')
const description = match[3]?.replace(/\\"/g, '"').replace(/\\n/g, '\n')
result[key] = description ? { title, description } : { title }
}
return Object.keys(result).length > 0 ? result : null
}
/**
* Try to parse partial JSON for streaming plan steps.
* Attempts to extract complete key-value pairs from incomplete JSON.
* Parses partial JSON for streaming plan steps, extracting complete key-value pairs from incomplete JSON.
* @param jsonStr - The potentially incomplete JSON string
* @returns Parsed plan steps record or null if no valid steps found
*/
function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null {
// Try parsing as-is first (might be complete)
@@ -159,7 +163,10 @@ function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null
}
/**
* Parse <plan> and <options> tags from content
* Parses special XML tags (`<plan>` and `<options>`) from message content.
* Handles both complete and streaming/incomplete tags.
* @param content - The message content to parse
* @returns Parsed tags with plan, options, and clean content
*/
export function parseSpecialTags(content: string): ParsedTags {
const result: ParsedTags = { cleanContent: content }
@@ -167,12 +174,18 @@ export function parseSpecialTags(content: string): ParsedTags {
// Parse <plan> tag - check for complete tag first
const planMatch = content.match(/<plan>([\s\S]*?)<\/plan>/i)
if (planMatch) {
// Always strip the tag from display, even if JSON is invalid
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
try {
result.plan = JSON.parse(planMatch[1])
result.planComplete = true
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
} catch {
// Invalid JSON, ignore
// JSON.parse failed - use regex fallback to extract plan from malformed JSON
const fallbackPlan = parsePartialPlanJson(planMatch[1])
if (fallbackPlan) {
result.plan = fallbackPlan
result.planComplete = true
}
}
} else {
// Check for streaming/incomplete plan tag
@@ -191,12 +204,18 @@ export function parseSpecialTags(content: string): ParsedTags {
// Parse <options> tag - check for complete tag first
const optionsMatch = content.match(/<options>([\s\S]*?)<\/options>/i)
if (optionsMatch) {
// Always strip the tag from display, even if JSON is invalid
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
try {
result.options = JSON.parse(optionsMatch[1])
result.optionsComplete = true
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
} catch {
// Invalid JSON, ignore
// JSON.parse failed - use regex fallback to extract options from malformed JSON
const fallbackOptions = parsePartialOptionsJson(optionsMatch[1])
if (fallbackOptions) {
result.options = fallbackOptions
result.optionsComplete = true
}
}
} else {
// Check for streaming/incomplete options tag
@@ -220,15 +239,15 @@ export function parseSpecialTags(content: string): ParsedTags {
}
/**
* PlanSteps component renders the workflow plan steps from the plan subagent
* Displays as a to-do list with checkmarks and strikethrough text
* Renders workflow plan steps as a numbered to-do list.
* @param steps - Plan steps keyed by step number
* @param streaming - When true, uses smooth streaming animation for step titles
*/
function PlanSteps({
steps,
streaming = false,
}: {
steps: Record<string, PlanStep>
/** When true, uses smooth streaming animation for step titles */
streaming?: boolean
}) {
const sortedSteps = useMemo(() => {
@@ -249,7 +268,7 @@ function PlanSteps({
if (sortedSteps.length === 0) return null
return (
<div className='mt-1.5 overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<div className='mt-0 overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<LayoutList className='ml-[2px] h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
<span className='font-medium text-[12px] text-[var(--text-primary)]'>To-dos</span>
@@ -257,7 +276,7 @@ function PlanSteps({
{sortedSteps.length}
</span>
</div>
<div className='flex flex-col gap-[6px] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] px-[10px] py-[6px]'>
{sortedSteps.map(([num, title], index) => {
const isLastStep = index === sortedSteps.length - 1
return (
@@ -281,9 +300,8 @@ function PlanSteps({
}
/**
* OptionsSelector component renders selectable options from the agent
* Supports keyboard navigation (arrow up/down, enter) and click selection
* After selection, shows the chosen option highlighted and others struck through
* Renders selectable options from the agent with keyboard navigation and click selection.
* After selection, shows the chosen option highlighted and others struck through.
*/
export function OptionsSelector({
options,
@@ -291,6 +309,7 @@ export function OptionsSelector({
disabled = false,
enableKeyboardNav = false,
streaming = false,
selectedOptionKey = null,
}: {
options: Record<string, OptionItem>
onSelect: (optionKey: string, optionText: string) => void
@@ -299,6 +318,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(() => {
@@ -316,8 +337,8 @@ export function OptionsSelector({
})
}, [options])
const [hoveredIndex, setHoveredIndex] = useState(0)
const [chosenKey, setChosenKey] = useState<string | null>(null)
const [hoveredIndex, setHoveredIndex] = useState(-1)
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
const containerRef = useRef<HTMLDivElement>(null)
const isLocked = chosenKey !== null
@@ -327,7 +348,8 @@ export function OptionsSelector({
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if the container or document body is focused (not when typing in input)
if (e.defaultPrevented) return
const activeElement = document.activeElement
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
@@ -338,13 +360,14 @@ export function OptionsSelector({
if (e.key === 'ArrowDown') {
e.preventDefault()
setHoveredIndex((prev) => Math.min(prev + 1, sortedOptions.length - 1))
setHoveredIndex((prev) => (prev < 0 ? 0 : Math.min(prev + 1, sortedOptions.length - 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHoveredIndex((prev) => Math.max(prev - 1, 0))
setHoveredIndex((prev) => (prev < 0 ? sortedOptions.length - 1 : Math.max(prev - 1, 0)))
} else if (e.key === 'Enter') {
e.preventDefault()
const selected = sortedOptions[hoveredIndex]
const indexToSelect = hoveredIndex < 0 ? 0 : hoveredIndex
const selected = sortedOptions[indexToSelect]
if (selected) {
setChosenKey(selected.key)
onSelect(selected.key, selected.title)
@@ -368,7 +391,7 @@ export function OptionsSelector({
if (sortedOptions.length === 0) return null
return (
<div ref={containerRef} className='flex flex-col gap-0.5 pb-0.5'>
<div ref={containerRef} className='flex flex-col gap-[4px] pt-[4px]'>
{sortedOptions.map((option, index) => {
const isHovered = index === hoveredIndex && !isLocked
const isChosen = option.key === chosenKey
@@ -386,6 +409,9 @@ export function OptionsSelector({
onMouseEnter={() => {
if (!isLocked && !streaming) setHoveredIndex(index)
}}
onMouseLeave={() => {
if (!isLocked && !streaming && sortedOptions.length === 1) setHoveredIndex(-1)
}}
className={clsx(
'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1',
'hover:bg-[var(--surface-4)]',
@@ -421,30 +447,31 @@ export function OptionsSelector({
)
}
/** Props for the ToolCall component */
interface ToolCallProps {
/** Tool call data object */
toolCall?: CopilotToolCall
/** Tool call ID for store lookup */
toolCallId?: string
/** Callback when tool call state changes */
onStateChange?: (state: any) => void
/** Whether this tool call is from the current/latest message. Controls shimmer and action buttons. */
isCurrentMessage?: boolean
}
/**
* Props for shimmer overlay text component.
*/
/** Props for the ShimmerOverlayText component */
interface ShimmerOverlayTextProps {
/** The text content to display */
/** Text content to display */
text: string
/** Whether the shimmer animation is active */
/** Whether shimmer animation is active */
active?: boolean
/** Additional class names for the wrapper */
className?: string
/** Whether to use special gradient styling (for important actions) */
/** Whether to use special gradient styling for important actions */
isSpecial?: boolean
}
/**
* Action verbs that appear at the start of tool display names.
* These will be highlighted in a lighter color for better visual hierarchy.
*/
/** Action verbs at the start of tool display names, highlighted for visual hierarchy */
const ACTION_VERBS = [
'Analyzing',
'Analyzed',
@@ -552,7 +579,8 @@ const ACTION_VERBS = [
/**
* Splits text into action verb and remainder for two-tone rendering.
* Returns [actionVerb, remainder] or [null, text] if no match.
* @param text - The text to split
* @returns Tuple of [actionVerb, remainder] or [null, text] if no match
*/
function splitActionVerb(text: string): [string | null, string] {
for (const verb of ACTION_VERBS) {
@@ -572,10 +600,9 @@ function splitActionVerb(text: string): [string | null, string] {
}
/**
* Renders text with a subtle white shimmer overlay when active, creating a skeleton-like
* loading effect that passes over the existing words without replacing them.
* For special tool calls, uses a gradient color. For normal tools, highlights action verbs
* in a lighter color with the rest in default gray.
* Renders text with a shimmer overlay animation when active.
* Special tools use a gradient color; normal tools highlight action verbs.
* Uses CSS truncation to clamp to one line with ellipsis.
*/
const ShimmerOverlayText = memo(function ShimmerOverlayText({
text,
@@ -585,10 +612,13 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
}: ShimmerOverlayTextProps) {
const [actionVerb, remainder] = splitActionVerb(text)
// Base classes for single-line truncation with ellipsis
const truncateClasses = 'block w-full overflow-hidden text-ellipsis whitespace-nowrap'
// Special tools: use tertiary-2 color for entire text with shimmer
if (isSpecial) {
return (
<span className={`relative inline-block ${className || ''}`}>
<span className={`relative ${truncateClasses} ${className || ''}`}>
<span className='text-[var(--brand-tertiary-2)]'>{text}</span>
{active ? (
<span
@@ -596,7 +626,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(51,196,129,0) 0%, rgba(255,255,255,0.6) 50%, rgba(51,196,129,0) 100%)',
@@ -627,7 +657,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
// Light mode: primary (#2d2d2d) vs muted (#737373) for good contrast
// Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast
return (
<span className={`relative inline-block ${className || ''}`}>
<span className={`relative ${truncateClasses} ${className || ''}`}>
{actionVerb ? (
<>
<span className='text-[var(--text-primary)] dark:text-[var(--text-tertiary)]'>
@@ -644,7 +674,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
@@ -672,8 +702,9 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
})
/**
* Get the outer collapse header label for completed subagent tools.
* Uses the tool's UI config.
* Gets the collapse header label for completed subagent tools.
* @param toolName - The tool name to get the label for
* @returns The completion label from UI config, defaults to 'Thought'
*/
function getSubagentCompletionLabel(toolName: string): string {
const labels = getSubagentLabelsFromConfig(toolName, false)
@@ -681,8 +712,9 @@ function getSubagentCompletionLabel(toolName: string): string {
}
/**
* SubAgentThinkingContent renders subagent blocks as simple thinking text (ThinkingBlock).
* Used for inline rendering within regular tool calls that have subagent content.
* Renders subagent blocks as thinking text within regular tool calls.
* @param blocks - The subagent content blocks to render
* @param isStreaming - Whether streaming animations should be shown (caller should pre-compute currentMessage check)
*/
function SubAgentThinkingContent({
blocks,
@@ -717,7 +749,7 @@ function SubAgentThinkingContent({
const hasSpecialTags = hasPlan
return (
<div className='space-y-1.5'>
<div className='space-y-[4px]'>
{cleanText.trim() && (
<ThinkingBlock
content={cleanText}
@@ -731,32 +763,29 @@ function SubAgentThinkingContent({
)
}
/**
* Subagents that should collapse when done streaming.
* Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.).
* Only plan, debug, and research collapse into summary headers.
*/
/** Subagents that collapse into summary headers when done streaming */
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research'])
/**
* SubagentContentRenderer handles the rendering of subagent content.
* - During streaming: Shows content at top level
* - When done (not streaming): Most subagents stay expanded, only specific ones collapse
* - Exception: plan, debug, research, info subagents collapse into a header
* Handles rendering of subagent content with streaming and collapse behavior.
*/
const SubagentContentRenderer = memo(function SubagentContentRenderer({
toolCall,
shouldCollapse,
isCurrentMessage = true,
}: {
toolCall: CopilotToolCall
shouldCollapse: boolean
/** Whether this is from the current/latest message. Controls shimmer animations. */
isCurrentMessage?: boolean
}) {
const [isExpanded, setIsExpanded] = useState(true)
const [duration, setDuration] = useState(0)
const startTimeRef = useRef<number>(Date.now())
const wasStreamingRef = useRef(false)
const isStreaming = !!toolCall.subAgentStreaming
// Only show streaming animations for current message
const isStreaming = isCurrentMessage && !!toolCall.subAgentStreaming
useEffect(() => {
if (isStreaming && !wasStreamingRef.current) {
@@ -850,7 +879,11 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
}
return (
<div key={`tool-${segment.block.toolCall.id || index}`}>
<ToolCall toolCallId={segment.block.toolCall.id} toolCall={segment.block.toolCall} />
<ToolCall
toolCallId={segment.block.toolCall.id}
toolCall={segment.block.toolCall}
isCurrentMessage={isCurrentMessage}
/>
</div>
)
}
@@ -861,7 +894,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
if (isStreaming || !shouldCollapse) {
return (
<div className='w-full space-y-1.5'>
<div className='w-full space-y-[4px]'>
{renderCollapsibleContent()}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div>
@@ -888,30 +921,30 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
<div
className={clsx(
'overflow-hidden transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
isExpanded ? 'mt-1.5 max-h-[5000px] space-y-[4px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{renderCollapsibleContent()}
</div>
{/* Plan stays outside the collapsible */}
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
{hasPlan && planToRender && (
<div className='mt-[6px]'>
<PlanSteps steps={planToRender} />
</div>
)}
</div>
)
})
/**
* Determines if a tool call is "special" and should display with gradient styling.
* Uses the tool's UI config.
* Determines if a tool call should display with special gradient styling.
*/
function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
return isSpecialToolFromConfig(toolCall.name)
}
/**
* WorkflowEditSummary shows a full-width summary of workflow edits (like Cursor's diff).
* Displays: workflow name with stats (+N green, N orange, -N red)
* Expands inline on click to show individual blocks with their icons.
* Displays a summary of workflow edits with added, edited, and deleted blocks.
*/
const WorkflowEditSummary = memo(function WorkflowEditSummary({
toolCall,
@@ -1169,9 +1202,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
)
})
/**
* Checks if a tool is an integration tool (server-side executed, not a client tool)
*/
/** Checks if a tool is server-side executed (not a client tool) */
function isIntegrationTool(toolName: string): boolean {
return !CLASS_TOOL_METADATA[toolName]
}
@@ -1317,9 +1348,7 @@ function getDisplayName(toolCall: CopilotToolCall): string {
return `${stateVerb} ${formattedName}`
}
/**
* Get verb prefix based on tool state
*/
/** Gets verb prefix based on tool call state */
function getStateVerb(state: string): string {
switch (state) {
case 'pending':
@@ -1338,8 +1367,7 @@ function getStateVerb(state: string): string {
}
/**
* Format tool name for display
* e.g., "google_calendar_list_events" -> "Google Calendar List Events"
* Formats tool name for display (e.g., "google_calendar_list_events" -> "Google Calendar List Events")
*/
function formatToolName(name: string): string {
const baseName = name.replace(/_v\d+$/, '')
@@ -1415,7 +1443,7 @@ function RunSkipButtons({
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
return (
<div className='mt-1.5 flex gap-[6px]'>
<div className='mt-[10px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
{isProcessing ? 'Allowing...' : 'Allow'}
</Button>
@@ -1431,7 +1459,12 @@ function RunSkipButtons({
)
}
export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) {
export function ToolCall({
toolCall: toolCallProp,
toolCallId,
onStateChange,
isCurrentMessage = true,
}: ToolCallProps) {
const [, forceUpdate] = useState({})
// Get live toolCall from store to ensure we have the latest state
const effectiveId = toolCallId || toolCallProp?.id
@@ -1445,9 +1478,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const isExpandablePending =
toolCall?.state === 'pending' &&
(toolCall.name === 'make_api_request' ||
toolCall.name === 'set_global_workflow_variables' ||
toolCall.name === 'run_workflow')
(toolCall.name === 'make_api_request' || toolCall.name === 'set_global_workflow_variables')
const [expanded, setExpanded] = useState(isExpandablePending)
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
@@ -1522,6 +1553,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<SubagentContentRenderer
toolCall={toolCall}
shouldCollapse={COLLAPSIBLE_SUBAGENTS.has(toolCall.name)}
isCurrentMessage={isCurrentMessage}
/>
)
}
@@ -1550,37 +1582,34 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
// Check if tool has params table config (meaning it's expandable)
const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable
const isRunWorkflow = toolCall.name === 'run_workflow'
const isExpandableTool =
hasParamsTable ||
toolCall.name === 'make_api_request' ||
toolCall.name === 'set_global_workflow_variables' ||
toolCall.name === 'run_workflow'
toolCall.name === 'set_global_workflow_variables'
const showButtons = shouldShowRunSkipButtons(toolCall)
const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall)
// Check UI config for secondary action
// Check UI config for secondary action - only show for current message tool calls
const toolUIConfig = getToolUIConfig(toolCall.name)
const secondaryAction = toolUIConfig?.secondaryAction
const showSecondaryAction = secondaryAction?.showInStates.includes(
toolCall.state as ClientToolCallState
)
const isExecuting =
toolCall.state === (ClientToolCallState.executing as any) ||
toolCall.state === ('executing' as any)
// Legacy fallbacks for tools that haven't migrated to UI config
const showMoveToBackground =
showSecondaryAction && secondaryAction?.text === 'Move to Background'
? true
: !secondaryAction &&
toolCall.name === 'run_workflow' &&
(toolCall.state === (ClientToolCallState.executing as any) ||
toolCall.state === ('executing' as any))
isCurrentMessage &&
((showSecondaryAction && secondaryAction?.text === 'Move to Background') ||
(!secondaryAction && toolCall.name === 'run_workflow' && isExecuting))
const showWake =
showSecondaryAction && secondaryAction?.text === 'Wake'
? true
: !secondaryAction &&
toolCall.name === 'sleep' &&
(toolCall.state === (ClientToolCallState.executing as any) ||
toolCall.state === ('executing' as any))
isCurrentMessage &&
((showSecondaryAction && secondaryAction?.text === 'Wake') ||
(!secondaryAction && toolCall.name === 'sleep' && isExecuting))
const handleStateChange = (state: any) => {
forceUpdate({})
@@ -1594,6 +1623,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
toolCall.state === ClientToolCallState.pending ||
toolCall.state === ClientToolCallState.executing
const shouldShowShimmer = isCurrentMessage && isLoadingState
const isSpecial = isSpecialToolCall(toolCall)
const renderPendingDetails = () => {
@@ -1903,7 +1934,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
<div className='flex flex-col pt-[6px]'>
{inputEntries.map(([key, value], index) => {
const isComplex = isComplexValue(value)
const displayValue = formatValueForDisplay(value)
@@ -1912,8 +1943,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<div
key={key}
className={clsx(
'flex flex-col gap-1.5 px-[10px] py-[8px]',
index > 0 && 'border-[var(--border-1)] border-t'
'flex flex-col gap-[6px] px-[10px] pb-[6px]',
index > 0 && 'mt-[6px] border-[var(--border-1)] border-t pt-[6px]'
)}
>
{/* Input key */}
@@ -2005,14 +2036,14 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<div className={isEnvVarsClickable ? 'cursor-pointer' : ''} onClick={handleEnvVarsClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
active={shouldShowShimmer}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
<div className='mt-1.5'>{renderPendingDetails()}</div>
<div className='mt-[10px]'>{renderPendingDetails()}</div>
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
@@ -2037,7 +2068,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentThinkingContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
/>
)}
</div>
@@ -2062,18 +2093,18 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
active={shouldShowShimmer}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
{code && (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Code.Viewer code={code} language='javascript' showGutter className='min-h-0' />
</div>
)}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
@@ -2098,14 +2129,14 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentThinkingContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
/>
)}
</div>
)
}
const isToolNameClickable = isExpandableTool || isAutoAllowed
const isToolNameClickable = (!isRunWorkflow && isExpandableTool) || isAutoAllowed
const handleToolNameClick = () => {
if (isExpandableTool) {
@@ -2116,6 +2147,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
const isEditWorkflow = toolCall.name === 'edit_workflow'
const shouldShowDetails = isRunWorkflow || (isExpandableTool && expanded)
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
@@ -2125,15 +2157,15 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
active={shouldShowShimmer}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
)}
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
{shouldShowDetails && <div className='mt-[10px]'>{renderPendingDetails()}</div>}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
@@ -2154,7 +2186,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
editedParams={editedParams}
/>
) : showMoveToBackground ? (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Button
onClick={async () => {
try {
@@ -2175,7 +2207,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
</Button>
</div>
) : showWake ? (
<div className='mt-1.5'>
<div className='mt-[10px]'>
<Button
onClick={async () => {
try {
@@ -2208,7 +2240,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentThinkingContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
/>
)}
</div>
@@ -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>
)
}
@@ -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'
@@ -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(() => {
@@ -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'
@@ -263,7 +267,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (q && q.length > 0) {
void mentionData.ensurePastChatsLoaded()
// workflows and workflow-blocks auto-load from stores
void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded()
@@ -306,7 +309,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 +660,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleModelSelect = useCallback(
(model: string) => {
setSelectedModel(model as any)
setSelectedModel(model as CopilotModelId)
},
[setSelectedModel]
)
@@ -677,15 +680,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 +699,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 +717,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 +859,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.
@@ -2,9 +2,7 @@
import { Button } from '@/components/emcn'
/**
* Props for the CopilotWelcome component
*/
/** Props for the Welcome component */
interface WelcomeProps {
/** Callback when a suggested question is clicked */
onQuestionClick?: (question: string) => void
@@ -12,13 +10,7 @@ interface WelcomeProps {
mode?: 'ask' | 'build' | 'plan'
}
/**
* Welcome screen component for the copilot
* Displays suggested questions and capabilities based on current mode
*
* @param props - Component props
* @returns Welcome screen UI
*/
/** Welcome screen displaying suggested questions based on current mode */
export function Welcome({ onQuestionClick, mode = 'ask' }: WelcomeProps) {
const capabilities =
mode === 'build'
@@ -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()
@@ -126,7 +130,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
// Handle scroll management (80px stickiness for copilot)
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
stickinessThreshold: 80,
stickinessThreshold: 40,
})
// Handle chat history grouping
@@ -146,15 +150,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
isSendingMessage,
showPlanTodos,
planTodos,
setPlanTodos,
})
/**
* Get markdown content for design document section
* Available in all modes once created
*/
/** Gets markdown content for design document section (available in all modes once created) */
const designDocumentContent = useMemo(() => {
// Use streaming content if available
if (streamingPlanContent) {
logger.info('[DesignDocument] Using streaming plan content', {
contentLength: streamingPlanContent.length,
@@ -165,9 +164,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
return ''
}, [streamingPlanContent])
/**
* Helper function to focus the copilot input
*/
/** Focuses the copilot input */
const focusInput = useCallback(() => {
userInputRef.current?.focus()
}, [])
@@ -181,20 +178,14 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
currentInputValue: inputValue,
})
/**
* Auto-scroll to bottom when chat loads in
*/
/** Auto-scrolls to bottom when chat loads */
useEffect(() => {
if (isInitialized && messages.length > 0) {
scrollToBottom()
}
}, [isInitialized, messages.length, scrollToBottom])
/**
* Cleanup on component unmount (page refresh, navigation, etc.)
* Uses a ref to track sending state to avoid stale closure issues
* Note: Parent workflow.tsx also has useStreamCleanup for page-level cleanup
*/
/** Cleanup on unmount - aborts active streaming. Uses refs to avoid stale closures */
const isSendingRef = useRef(isSendingMessage)
isSendingRef.current = isSendingMessage
const abortMessageRef = useRef(abortMessage)
@@ -202,19 +193,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
useEffect(() => {
return () => {
// Use refs to check current values, not stale closure values
if (isSendingRef.current) {
abortMessageRef.current()
logger.info('Aborted active message streaming due to component unmount')
}
}
// Empty deps - only run cleanup on actual unmount, not on re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/**
* Container-level click capture to cancel edit mode when clicking outside the current edit area
*/
/** Cancels edit mode when clicking outside the current edit area */
const handleCopilotClickCapture = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!isEditingMessage) return
@@ -243,10 +230,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[isEditingMessage, editingMessageId]
)
/**
* Handles creating a new chat session
* Focuses the input after creation
*/
/** Creates a new chat session and focuses the input */
const handleStartNewChat = useCallback(() => {
createNewChat()
logger.info('Started new chat')
@@ -256,10 +240,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}, 100)
}, [createNewChat])
/**
* Sets the input value and focuses the textarea
* @param value - The value to set in the input
*/
/** Sets the input value and focuses the textarea */
const handleSetInputValueAndFocus = useCallback(
(value: string) => {
setInputValue(value)
@@ -270,7 +251,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[setInputValue]
)
// Expose functions to parent
/** Exposes imperative functions to parent */
useImperativeHandle(
ref,
() => ({
@@ -281,10 +262,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[handleStartNewChat, handleSetInputValueAndFocus, focusInput]
)
/**
* Handles aborting the current message streaming
* Collapses todos if they are currently shown
*/
/** Aborts current message streaming and collapses todos if shown */
const handleAbort = useCallback(() => {
abortMessage()
if (showPlanTodos) {
@@ -292,20 +270,20 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}
}, [abortMessage, showPlanTodos])
/**
* Handles message submission to the copilot
* @param query - The message text to send
* @param fileAttachments - Optional file attachments
* @param contexts - Optional context references
*/
/** Closes the plan todos section and clears the todos */
const handleClosePlanTodos = useCallback(() => {
closePlanTodos()
setPlanTodos([])
}, [closePlanTodos, setPlanTodos])
/** Handles message submission to the copilot */
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,37 +297,25 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[activeWorkflowId, sendMessage, showPlanTodos]
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
)
/**
* Handles message edit mode changes
* @param messageId - ID of the message being edited
* @param isEditing - Whether edit mode is active
*/
/** Handles message edit mode changes */
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 })
},
[]
)
/**
* Handles checkpoint revert mode changes
* @param messageId - ID of the message being reverted
* @param isReverting - Whether revert mode is active
*/
/** Handles checkpoint revert mode changes */
const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => {
setRevertingMessageId(isReverting ? messageId : null)
}, [])
/**
* Handles chat deletion
* @param chatId - ID of the chat to delete
*/
/** Handles chat deletion */
const handleDeleteChat = useCallback(
async (chatId: string) => {
try {
@@ -361,38 +327,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[deleteChat]
)
/**
* Handles history dropdown opening state
* Loads chats if needed when dropdown opens (non-blocking)
* @param open - Whether the dropdown is open
*/
/** Handles history dropdown opening state, loads chats if needed (non-blocking) */
const handleHistoryDropdownOpen = useCallback(
(open: boolean) => {
setIsHistoryDropdownOpen(open)
// Fire hook without awaiting - prevents blocking and state issues
handleHistoryDropdownOpenHook(open)
},
[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
@@ -531,21 +474,18 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
className='h-full overflow-y-auto overflow-x-hidden px-[8px]'
>
<div
className={`w-full max-w-full space-y-4 overflow-hidden py-[8px] ${
className={`w-full max-w-full space-y-[8px] overflow-hidden py-[8px] ${
showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10'
}`}
>
{messages.map((message, index) => {
// Determine if this message should be dimmed
let isDimmed = false
// Dim messages after the one being edited
if (editingMessageId) {
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
isDimmed = editingIndex !== -1 && index > editingIndex
}
// Also dim messages after the one showing restore confirmation
if (!isDimmed && revertingMessageId) {
const revertingIndex = messages.findIndex(
(m) => m.id === revertingMessageId
@@ -553,7 +493,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
isDimmed = revertingIndex !== -1 && index > revertingIndex
}
// Get checkpoint count for this message to force re-render when it changes
const checkpointCount = messageCheckpoints[message.id]?.length || 0
return (
@@ -588,11 +527,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>
)}
@@ -24,9 +24,7 @@ export function useChatHistory(props: UseChatHistoryProps) {
const { chats, activeWorkflowId, copilotWorkflowId, loadChats, areChatsFresh, isSendingMessage } =
props
/**
* Groups chats by time period (Today, Yesterday, This Week, etc.)
*/
/** Groups chats by time period (Today, Yesterday, This Week, etc.) */
const groupedChats = useMemo(() => {
if (!activeWorkflowId || copilotWorkflowId !== activeWorkflowId || chats.length === 0) {
return []
@@ -68,18 +66,21 @@ export function useChatHistory(props: UseChatHistoryProps) {
}
})
for (const groupName of Object.keys(groups)) {
groups[groupName].sort((a, b) => {
const dateA = new Date(a.updatedAt).getTime()
const dateB = new Date(b.updatedAt).getTime()
return dateB - dateA
})
}
return Object.entries(groups).filter(([, chats]) => chats.length > 0)
}, [chats, activeWorkflowId, copilotWorkflowId])
/**
* Handles history dropdown opening and loads chats if needed
* Does not await loading - fires in background to avoid blocking UI
*/
/** Handles history dropdown opening and loads chats if needed (non-blocking) */
const handleHistoryDropdownOpen = useCallback(
(open: boolean) => {
// Only load if opening dropdown AND we don't have fresh chats AND not streaming
if (open && activeWorkflowId && !isSendingMessage && !areChatsFresh(activeWorkflowId)) {
// Fire in background, don't await - same pattern as old panel
loadChats(false).catch((error) => {
logger.error('Failed to load chat history:', error)
})
@@ -38,11 +38,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
const lastWorkflowIdRef = useRef<string | null>(null)
const hasMountedRef = useRef(false)
/**
* Initialize on mount - only load chats if needed, don't force refresh
* This prevents unnecessary reloads when the component remounts (e.g., hot reload)
* Never loads during message streaming to prevent interrupting active conversations
*/
/** Initialize on mount - loads chats if needed. Never loads during streaming */
useEffect(() => {
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
hasMountedRef.current = true
@@ -50,19 +46,12 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
lastWorkflowIdRef.current = null
setCopilotWorkflowId(activeWorkflowId)
// Use false to let the store decide if a reload is needed based on cache
loadChats(false)
}
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
/**
* Initialize the component - only on mount and genuine workflow changes
* Prevents re-initialization on every render or tab switch
* Never reloads during message streaming to preserve active conversations
*/
/** Handles genuine workflow changes, preventing re-init on every render */
useEffect(() => {
// Handle genuine workflow changes (not initial mount, not same workflow)
// Only reload if not currently streaming to avoid interrupting conversations
if (
activeWorkflowId &&
activeWorkflowId !== lastWorkflowIdRef.current &&
@@ -80,7 +69,23 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
loadChats(false)
}
// Mark as initialized when chats are loaded for the active workflow
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)
}
if (
activeWorkflowId &&
!isLoadingChats &&
@@ -100,9 +105,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage,
])
/**
* Load auto-allowed tools once on mount
*/
/** Load auto-allowed tools once on mount */
const hasLoadedAutoAllowedToolsRef = useRef(false)
useEffect(() => {
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
@@ -6,7 +6,6 @@ interface UseTodoManagementProps {
isSendingMessage: boolean
showPlanTodos: boolean
planTodos: Array<{ id: string; content: string; completed?: boolean }>
setPlanTodos: (todos: any[]) => void
}
/**
@@ -16,14 +15,12 @@ interface UseTodoManagementProps {
* @returns Todo management utilities
*/
export function useTodoManagement(props: UseTodoManagementProps) {
const { isSendingMessage, showPlanTodos, planTodos, setPlanTodos } = props
const { isSendingMessage, showPlanTodos, planTodos } = props
const [todosCollapsed, setTodosCollapsed] = useState(false)
const wasSendingRef = useRef(false)
/**
* Auto-collapse todos when stream completes. Do not prune items.
*/
/** Auto-collapse todos when stream completes */
useEffect(() => {
if (wasSendingRef.current && !isSendingMessage && showPlanTodos) {
setTodosCollapsed(true)
@@ -31,9 +28,7 @@ export function useTodoManagement(props: UseTodoManagementProps) {
wasSendingRef.current = isSendingMessage
}, [isSendingMessage, showPlanTodos])
/**
* Reset collapsed state when todos first appear
*/
/** Reset collapsed state when todos first appear */
useEffect(() => {
if (showPlanTodos && planTodos.length > 0) {
if (isSendingMessage) {
@@ -259,7 +259,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
code={code}
showGutter
language={language}
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)]'
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
@@ -624,7 +624,7 @@ const OutputPanel = React.memo(function OutputPanel({
</Tooltip.Content>
</Tooltip.Root>
)}
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen}>
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -648,7 +648,7 @@ const OutputPanel = React.memo(function OutputPanel({
>
<PopoverItem
active={wrapText}
showCheck
showCheck={wrapText}
onClick={(e) => {
e.stopPropagation()
setWrapText(!wrapText)
@@ -658,7 +658,7 @@ const OutputPanel = React.memo(function OutputPanel({
</PopoverItem>
<PopoverItem
active={openOnRun}
showCheck
showCheck={openOnRun}
onClick={(e) => {
e.stopPropagation()
setOpenOnRun(!openOnRun)
@@ -1472,7 +1472,7 @@ export const Terminal = memo(function Terminal() {
>
{uniqueBlocks.length > 0 ? (
<div className={clsx(COLUMN_WIDTHS.BLOCK, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen}>
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1508,12 +1508,12 @@ export const Terminal = memo(function Terminal() {
<PopoverItem
key={block.blockId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleBlock(block.blockId)}
className={index > 0 ? 'mt-[2px]' : ''}
>
{BlockIcon && <BlockIcon className='h-3 w-3' />}
<span className='flex-1'>{block.blockName}</span>
{isSelected && <Check className='h-3 w-3' />}
</PopoverItem>
)
})}
@@ -1526,7 +1526,7 @@ export const Terminal = memo(function Terminal() {
)}
{hasStatusEntries ? (
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen}>
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1555,6 +1555,7 @@ export const Terminal = memo(function Terminal() {
<PopoverScrollArea style={{ maxHeight: '140px' }}>
<PopoverItem
active={filters.statuses.has('error')}
showCheck={filters.statuses.has('error')}
onClick={() => toggleStatus('error')}
>
<div
@@ -1562,10 +1563,10 @@ export const Terminal = memo(function Terminal() {
style={{ backgroundColor: 'var(--text-error)' }}
/>
<span className='flex-1'>Error</span>
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
</PopoverItem>
<PopoverItem
active={filters.statuses.has('info')}
showCheck={filters.statuses.has('info')}
onClick={() => toggleStatus('info')}
className='mt-[2px]'
>
@@ -1574,7 +1575,6 @@ export const Terminal = memo(function Terminal() {
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
/>
<span className='flex-1'>Info</span>
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
@@ -1585,7 +1585,7 @@ export const Terminal = memo(function Terminal() {
)}
{uniqueRunIds.length > 0 ? (
<div className={clsx(COLUMN_WIDTHS.RUN_ID, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen}>
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1620,16 +1620,16 @@ export const Terminal = memo(function Terminal() {
<PopoverItem
key={runId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleRunId(runId)}
className={index > 0 ? 'mt-[2px]' : ''}
>
<span
className='flex-1 font-mono text-[12px]'
className='flex-1 font-mono text-[11px]'
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
{isSelected && <Check className='h-3 w-3' />}
</PopoverItem>
)
})}
@@ -1765,7 +1765,7 @@ export const Terminal = memo(function Terminal() {
</Tooltip.Root>
</>
)}
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen}>
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1789,7 +1789,7 @@ export const Terminal = memo(function Terminal() {
>
<PopoverItem
active={openOnRun}
showCheck
showCheck={openOnRun}
onClick={(e) => {
e.stopPropagation()
setOpenOnRun(!openOnRun)
@@ -31,9 +31,11 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
@@ -561,6 +563,59 @@ const SubBlockRow = memo(function SubBlockRow({
return `${names[0]}, ${names[1]} +${names.length - 2}`
}, [subBlock?.type, rawValue, workflowVariables])
/**
* Hydrates tool references to display names.
* Follows the same pattern as other selectors (Slack channels, MCP tools, etc.)
*/
const { data: customTools = [] } = useCustomTools(workspaceId || '')
const toolsDisplayValue = useMemo(() => {
if (subBlock?.type !== 'tool-input' || !Array.isArray(rawValue) || rawValue.length === 0) {
return null
}
const toolNames = rawValue
.map((tool: any) => {
if (!tool || typeof tool !== 'object') return null
// Priority 1: Use tool.title if already populated
if (tool.title && typeof tool.title === 'string') return tool.title
// Priority 2: Resolve custom tools with reference ID from database
if (tool.type === 'custom-tool' && tool.customToolId) {
const customTool = customTools.find((t) => t.id === tool.customToolId)
if (customTool?.title) return customTool.title
if (customTool?.schema?.function?.name) return customTool.schema.function.name
}
// Priority 3: Extract from inline schema (legacy format)
if (tool.schema?.function?.name) return tool.schema.function.name
// Priority 4: Extract from OpenAI function format
if (tool.function?.name) return tool.function.name
// Priority 5: Resolve built-in tool blocks from registry
if (
typeof tool.type === 'string' &&
tool.type !== 'custom-tool' &&
tool.type !== 'mcp' &&
tool.type !== 'workflow' &&
tool.type !== 'workflow_input'
) {
const blockConfig = getBlock(tool.type)
if (blockConfig?.name) return blockConfig.name
}
return null
})
.filter((name): name is string => !!name)
if (toolNames.length === 0) return null
if (toolNames.length === 1) return toolNames[0]
if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}`
return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}`
}, [subBlock?.type, rawValue, customTools, workspaceId])
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -569,6 +624,7 @@ const SubBlockRow = memo(function SubBlockRow({
credentialName ||
dropdownLabel ||
variablesDisplayValue ||
toolsDisplayValue ||
knowledgeBaseDisplayName ||
workflowSelectionName ||
mcpServerDisplayName ||
@@ -3,19 +3,20 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Options for configuring scroll behavior.
* Options for configuring scroll behavior
*/
interface UseScrollManagementOptions {
/**
* Scroll behavior for programmatic scrolls.
* - `smooth`: animated scroll (default, used by Copilot).
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
* Scroll behavior for programmatic scrolls
* @remarks
* - `smooth`: Animated scroll (default, used by Copilot)
* - `auto`: Immediate scroll to bottom (used by floating chat to avoid jitter)
*/
behavior?: 'auto' | 'smooth'
/**
* Distance from bottom (in pixels) within which auto-scroll stays active.
* Lower values = less sticky (user can scroll away easier).
* Default is 100px.
* Distance from bottom (in pixels) within which auto-scroll stays active
* @remarks Lower values = less sticky (user can scroll away easier)
* @defaultValue 100
*/
stickinessThreshold?: number
}
@@ -35,166 +36,105 @@ export function useScrollManagement(
options?: UseScrollManagementOptions
) {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [isNearBottom, setIsNearBottom] = useState(true)
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
const programmaticScrollInProgressRef = useRef(false)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const programmaticScrollRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
const scrollBehavior = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
*/
const getScrollContainer = useCallback((): HTMLElement | null => {
// Prefer the element with the ref (our scrollable div)
if (scrollAreaRef.current) return scrollAreaRef.current
return null
}, [])
/** Scrolls the container to the bottom */
const scrollToBottom = useCallback(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const container = scrollAreaRef.current
if (!container) return
programmaticScrollRef.current = true
container.scrollTo({ top: container.scrollHeight, behavior: scrollBehavior })
programmaticScrollInProgressRef.current = true
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: scrollBehavior,
})
// Best-effort reset; not all browsers fire scrollend reliably
window.setTimeout(() => {
programmaticScrollInProgressRef.current = false
programmaticScrollRef.current = false
}, 200)
}, [getScrollContainer, scrollBehavior])
}, [scrollBehavior])
/**
* Handles scroll events to track user position and show/hide scroll button
*/
/** Handles scroll events to track user position */
const handleScroll = useCallback(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const container = scrollAreaRef.current
if (!container || programmaticScrollRef.current) return
if (programmaticScrollInProgressRef.current) {
// Ignore scrolls we initiated
return
}
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
setIsNearBottom(nearBottom)
const delta = scrollTop - lastScrollTopRef.current
if (isSendingMessage) {
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -2 // small hysteresis to avoid noise
const movedDown = delta > 2
if (movedUp) {
// Any upward movement breaks away from sticky during streaming
setUserHasScrolledDuringStream(true)
// User scrolled up during streaming - break away
if (delta < -2) {
setUserHasScrolledAway(true)
}
// If the user has broken away and scrolls back down to the bottom, re-stick
if (userHasScrolledDuringStream && movedDown && nearBottom) {
setUserHasScrolledDuringStream(false)
// User scrolled back down to bottom - re-stick
if (userHasScrolledAway && delta > 2 && nearBottom) {
setUserHasScrolledAway(false)
}
}
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
}, [isSendingMessage, userHasScrolledAway, stickinessThreshold])
// Attach scroll listener
/** Attaches scroll listener to container */
useEffect(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const container = scrollAreaRef.current
if (!container) return
const handleUserScroll = () => {
handleScroll()
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
if ('onscrollend' in scrollContainer) {
scrollContainer.addEventListener('scrollend', handleScroll, { passive: true })
}
// Initialize state
window.setTimeout(handleScroll, 100)
// Initialize last scroll position
lastScrollTopRef.current = scrollContainer.scrollTop
return () => {
scrollContainer.removeEventListener('scroll', handleUserScroll)
if ('onscrollend' in scrollContainer) {
scrollContainer.removeEventListener('scrollend', handleScroll)
}
}
}, [getScrollContainer, handleScroll])
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
/** Handles auto-scroll when new messages are added */
useEffect(() => {
if (messages.length === 0) return
const lastMessage = messages[messages.length - 1]
const isNewUserMessage = lastMessage?.role === 'user'
const isUserMessage = lastMessage?.role === 'user'
const shouldAutoScroll =
isNewUserMessage ||
(isSendingMessage && !userHasScrolledDuringStream) ||
(!isSendingMessage && isNearBottom)
if (shouldAutoScroll) {
// Always scroll for user messages, respect scroll state for assistant messages
if (isUserMessage) {
setUserHasScrolledAway(false)
scrollToBottom()
} else if (!userHasScrolledAway) {
scrollToBottom()
}
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream, scrollToBottom])
}, [messages, userHasScrolledAway, scrollToBottom])
// Reset user scroll state when streaming starts or when user sends a message
/** Resets scroll state when streaming completes */
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
setUserHasScrolledDuringStream(false)
programmaticScrollInProgressRef.current = false
const scrollContainer = getScrollContainer()
if (scrollContainer) {
lastScrollTopRef.current = scrollContainer.scrollTop
}
if (!isSendingMessage) {
setUserHasScrolledAway(false)
}
}, [messages, getScrollContainer])
// Reset user scroll state when streaming completes
const prevIsSendingRef = useRef(false)
useEffect(() => {
if (prevIsSendingRef.current && !isSendingMessage) {
setUserHasScrolledDuringStream(false)
}
prevIsSendingRef.current = isSendingMessage
}, [isSendingMessage])
// While streaming and not broken away, keep pinned to bottom
/** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */
useEffect(() => {
if (!isSendingMessage || userHasScrolledDuringStream) return
// Early return stops the interval when user scrolls away (state change re-runs effect)
if (!isSendingMessage || userHasScrolledAway) {
return
}
const intervalId = window.setInterval(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const container = scrollAreaRef.current
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
if (nearBottom) {
if (distanceFromBottom > 1) {
scrollToBottom()
}
}, 100)
return () => window.clearInterval(intervalId)
}, [
isSendingMessage,
userHasScrolledDuringStream,
getScrollContainer,
scrollToBottom,
stickinessThreshold,
])
}, [isSendingMessage, userHasScrolledAway, scrollToBottom])
return {
scrollAreaRef,
@@ -1,19 +1,19 @@
/**
* Code editor syntax token theme.
* Light mode: Vibrant colors matching dark mode's aesthetic quality.
* Dark mode: VSCode Dark+ inspired colors with deep, vibrant palette.
* Cursor/VS Code base colors with Sim's vibrant saturation.
* Colors aligned to Sim brand where applicable.
* Applied to elements with .code-editor-theme class.
*/
/**
* Light mode token colors (default) - vibrant palette
* Light mode token colors - Cursor style with Sim vibrancy
*/
.code-editor-theme .token.comment,
.code-editor-theme .token.block-comment,
.code-editor-theme .token.prolog,
.code-editor-theme .token.doctype,
.code-editor-theme .token.cdata {
color: #2e7d32 !important;
color: #16a34a !important;
}
.code-editor-theme .token.punctuation {
@@ -30,7 +30,7 @@
.code-editor-theme .token.boolean,
.code-editor-theme .token.number,
.code-editor-theme .token.constant {
color: #b45309 !important;
color: #16a34a !important;
}
.code-editor-theme .token.string,
@@ -49,7 +49,7 @@
.code-editor-theme .token.atrule,
.code-editor-theme .token.attr-value,
.code-editor-theme .token.keyword {
color: #9333ea !important;
color: #2563eb !important;
}
.code-editor-theme .token.function,
@@ -76,68 +76,68 @@
}
/**
* Dark mode token colors
* Dark mode token colors - Cursor style with Sim vibrancy
*/
.dark .code-editor-theme .token.comment,
.dark .code-editor-theme .token.block-comment,
.dark .code-editor-theme .token.prolog,
.dark .code-editor-theme .token.doctype,
.dark .code-editor-theme .token.cdata {
color: #8bc985 !important;
color: #6ec97d !important;
}
.dark .code-editor-theme .token.punctuation {
color: #eeeeee !important;
color: #d4d4d4 !important;
}
.dark .code-editor-theme .token.property,
.dark .code-editor-theme .token.attr-name,
.dark .code-editor-theme .token.variable {
color: #5fc9cb !important;
color: #4fc3f7 !important;
}
.dark .code-editor-theme .token.tag,
.dark .code-editor-theme .token.boolean,
.dark .code-editor-theme .token.number,
.dark .code-editor-theme .token.constant {
color: #ffc857 !important;
color: #a5d6a7 !important;
}
.dark .code-editor-theme .token.string,
.dark .code-editor-theme .token.char,
.dark .code-editor-theme .token.builtin,
.dark .code-editor-theme .token.inserted {
color: #ff6b6b !important;
color: #f39c6b !important;
}
.dark .code-editor-theme .token.operator,
.dark .code-editor-theme .token.entity,
.dark .code-editor-theme .token.url {
color: #eeeeee !important;
color: #d4d4d4 !important;
}
.dark .code-editor-theme .token.atrule,
.dark .code-editor-theme .token.attr-value,
.dark .code-editor-theme .token.keyword {
color: #d896d8 !important;
color: #4db8ff !important;
}
.dark .code-editor-theme .token.function,
.dark .code-editor-theme .token.class-name {
color: #ffc857 !important;
color: #fbbf24 !important;
}
.dark .code-editor-theme .token.regex,
.dark .code-editor-theme .token.important {
color: #ff6b6b !important;
color: #f87171 !important;
}
.dark .code-editor-theme .token.symbol {
color: #eeeeee !important;
color: #d4d4d4 !important;
}
.dark .code-editor-theme .token.deleted {
color: #ff6b6b !important;
color: #f87171 !important;
}
/* Blue accents for <var> and {{ENV}} placeholders - dark mode */
@@ -26,21 +26,20 @@ export class GetExamplesRagClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
switch (state) {
case ClientToolCallState.success:
return `Found examples for ${truncated}`
return `Found examples for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching examples for ${truncated}`
return `Searching examples for ${query}`
case ClientToolCallState.error:
return `Failed to find examples for ${truncated}`
return `Failed to find examples for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching examples for ${truncated}`
return `Aborted searching examples for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching examples for ${truncated}`
return `Skipped searching examples for ${query}`
}
}
return undefined
@@ -32,21 +32,20 @@ export class GetOperationsExamplesClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
switch (state) {
case ClientToolCallState.success:
return `Designed ${truncated}`
return `Designed ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Designing ${truncated}`
return `Designing ${query}`
case ClientToolCallState.error:
return `Failed to design ${truncated}`
return `Failed to design ${query}`
case ClientToolCallState.aborted:
return `Aborted designing ${truncated}`
return `Aborted designing ${query}`
case ClientToolCallState.rejected:
return `Skipped designing ${truncated}`
return `Skipped designing ${query}`
}
}
return undefined
@@ -26,21 +26,20 @@ export class CrawlWebsiteClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Crawled ${truncated}`
return `Crawled ${url}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Crawling ${truncated}`
return `Crawling ${url}`
case ClientToolCallState.error:
return `Failed to crawl ${truncated}`
return `Failed to crawl ${url}`
case ClientToolCallState.aborted:
return `Aborted crawling ${truncated}`
return `Aborted crawling ${url}`
case ClientToolCallState.rejected:
return `Skipped crawling ${truncated}`
return `Skipped crawling ${url}`
}
}
return undefined
@@ -26,22 +26,21 @@ export class GetPageContentsClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
const firstUrl = String(params.urls[0])
const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl
const count = params.urls.length
switch (state) {
case ClientToolCallState.success:
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}`
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${firstUrl}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}`
return count > 1 ? `Getting ${count} pages` : `Getting ${firstUrl}`
case ClientToolCallState.error:
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}`
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${firstUrl}`
case ClientToolCallState.aborted:
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}`
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${firstUrl}`
case ClientToolCallState.rejected:
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}`
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${firstUrl}`
}
}
return undefined
@@ -63,14 +63,8 @@ export class MakeApiRequestClientTool extends BaseClientTool {
try {
const urlObj = new URL(url)
url = urlObj.hostname + urlObj.pathname
if (url.length > 40) {
url = `${url.slice(0, 40)}...`
}
} catch {
// If URL parsing fails, just truncate
if (url.length > 40) {
url = `${url.slice(0, 40)}...`
}
// Use URL as-is if parsing fails
}
switch (state) {
@@ -30,42 +30,38 @@ export class RememberDebugClientTool extends BaseClientTool {
// For add/edit, show from problem or solution
const text = params?.problem || params?.solution
if (text && typeof text === 'string') {
const truncated = text.length > 40 ? `${text.slice(0, 40)}...` : text
switch (state) {
case ClientToolCallState.success:
return `Validated fix ${truncated}`
return `Validated fix ${text}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Validating fix ${truncated}`
return `Validating fix ${text}`
case ClientToolCallState.error:
return `Failed to validate fix ${truncated}`
return `Failed to validate fix ${text}`
case ClientToolCallState.aborted:
return `Aborted validating fix ${truncated}`
return `Aborted validating fix ${text}`
case ClientToolCallState.rejected:
return `Skipped validating fix ${truncated}`
return `Skipped validating fix ${text}`
}
}
} else if (operation === 'delete') {
// For delete, show from problem or solution (or id as fallback)
const text = params?.problem || params?.solution || params?.id
if (text && typeof text === 'string') {
const truncated = text.length > 40 ? `${text.slice(0, 40)}...` : text
switch (state) {
case ClientToolCallState.success:
return `Adjusted fix ${truncated}`
return `Adjusted fix ${text}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Adjusting fix ${truncated}`
return `Adjusting fix ${text}`
case ClientToolCallState.error:
return `Failed to adjust fix ${truncated}`
return `Failed to adjust fix ${text}`
case ClientToolCallState.aborted:
return `Aborted adjusting fix ${truncated}`
return `Aborted adjusting fix ${text}`
case ClientToolCallState.rejected:
return `Skipped adjusting fix ${truncated}`
return `Skipped adjusting fix ${text}`
}
}
}
@@ -26,21 +26,20 @@ export class ScrapePageClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Scraped ${truncated}`
return `Scraped ${url}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Scraping ${truncated}`
return `Scraping ${url}`
case ClientToolCallState.error:
return `Failed to scrape ${truncated}`
return `Failed to scrape ${url}`
case ClientToolCallState.aborted:
return `Aborted scraping ${truncated}`
return `Aborted scraping ${url}`
case ClientToolCallState.rejected:
return `Skipped scraping ${truncated}`
return `Skipped scraping ${url}`
}
}
return undefined
@@ -33,21 +33,20 @@ export class SearchDocumentationClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
const truncated = query.length > 50 ? `${query.slice(0, 50)}...` : query
switch (state) {
case ClientToolCallState.success:
return `Searched docs for ${truncated}`
return `Searched docs for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching docs for ${truncated}`
return `Searching docs for ${query}`
case ClientToolCallState.error:
return `Failed to search docs for ${truncated}`
return `Failed to search docs for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching docs for ${truncated}`
return `Aborted searching docs for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching docs for ${truncated}`
return `Skipped searching docs for ${query}`
}
}
return undefined
@@ -26,21 +26,20 @@ export class SearchErrorsClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
const truncated = query.length > 50 ? `${query.slice(0, 50)}...` : query
switch (state) {
case ClientToolCallState.success:
return `Debugged ${truncated}`
return `Debugged ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Debugging ${truncated}`
return `Debugging ${query}`
case ClientToolCallState.error:
return `Failed to debug ${truncated}`
return `Failed to debug ${query}`
case ClientToolCallState.aborted:
return `Aborted debugging ${truncated}`
return `Aborted debugging ${query}`
case ClientToolCallState.rejected:
return `Skipped debugging ${truncated}`
return `Skipped debugging ${query}`
}
}
return undefined
@@ -26,21 +26,20 @@ export class SearchOnlineClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
const truncated = query.length > 50 ? `${query.slice(0, 50)}...` : query
switch (state) {
case ClientToolCallState.success:
return `Searched online for ${truncated}`
return `Searched online for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching online for ${truncated}`
return `Searching online for ${query}`
case ClientToolCallState.error:
return `Failed to search online for ${truncated}`
return `Failed to search online for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching online for ${truncated}`
return `Aborted searching online for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching online for ${truncated}`
return `Skipped searching online for ${query}`
}
}
return undefined
@@ -26,21 +26,20 @@ export class SearchPatternsClientTool extends BaseClientTool {
getDynamicText: (params, state) => {
if (params?.queries && Array.isArray(params.queries) && params.queries.length > 0) {
const firstQuery = String(params.queries[0])
const truncated = firstQuery.length > 50 ? `${firstQuery.slice(0, 50)}...` : firstQuery
switch (state) {
case ClientToolCallState.success:
return `Searched ${truncated}`
return `Searched ${firstQuery}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching ${truncated}`
return `Searching ${firstQuery}`
case ClientToolCallState.error:
return `Failed to search ${truncated}`
return `Failed to search ${firstQuery}`
case ClientToolCallState.aborted:
return `Aborted searching ${truncated}`
return `Aborted searching ${firstQuery}`
case ClientToolCallState.rejected:
return `Skipped searching ${truncated}`
return `Skipped searching ${firstQuery}`
}
}
return undefined
+11
View File
@@ -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',
})
}
@@ -2638,6 +2644,11 @@ export const useCopilotStore = create<CopilotStore>()(
currentChat: state.currentChat
? { ...state.currentChat, title: optimisticTitle }
: state.currentChat,
chats: state.currentChat
? state.chats.map((c) =>
c.id === state.currentChat!.id ? { ...c, title: optimisticTitle } : c
)
: state.chats,
}))
}