From 93955ff28babf20a3b28ff1d90968bb7d2150b36 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 19 Jan 2026 12:45:00 -0800 Subject: [PATCH 1/9] feat(claude): added rules --- .claude/rules/emcn-components.md | 35 +++ .claude/rules/global.md | 13 ++ .claude/rules/sim-architecture.md | 56 +++++ .claude/rules/sim-components.md | 48 ++++ .claude/rules/sim-hooks.md | 55 +++++ .claude/rules/sim-imports.md | 62 ++++++ .claude/rules/sim-integrations.md | 209 ++++++++++++++++++ .claude/rules/sim-queries.md | 66 ++++++ .claude/rules/sim-stores.md | 71 ++++++ .claude/rules/sim-styling.md | 41 ++++ .claude/rules/sim-testing.md | 58 +++++ .claude/rules/sim-typescript.md | 21 ++ .../hooks/use-copilot-initialization.ts | 19 ++ apps/sim/stores/panel/copilot/store.ts | 1 + 14 files changed, 755 insertions(+) create mode 100644 .claude/rules/emcn-components.md create mode 100644 .claude/rules/global.md create mode 100644 .claude/rules/sim-architecture.md create mode 100644 .claude/rules/sim-components.md create mode 100644 .claude/rules/sim-hooks.md create mode 100644 .claude/rules/sim-imports.md create mode 100644 .claude/rules/sim-integrations.md create mode 100644 .claude/rules/sim-queries.md create mode 100644 .claude/rules/sim-stores.md create mode 100644 .claude/rules/sim-styling.md create mode 100644 .claude/rules/sim-testing.md create mode 100644 .claude/rules/sim-typescript.md diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md new file mode 100644 index 0000000000..011a3280f4 --- /dev/null +++ b/.claude/rules/emcn-components.md @@ -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 +} +``` + +## 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 diff --git a/.claude/rules/global.md b/.claude/rules/global.md new file mode 100644 index 0000000000..e749b67b28 --- /dev/null +++ b/.claude/rules/global.md @@ -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`. diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md new file mode 100644 index 0000000000..d6d7197972 --- /dev/null +++ b/.claude/rules/sim-architecture.md @@ -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) diff --git a/.claude/rules/sim-components.md b/.claude/rules/sim-components.md new file mode 100644 index 0000000000..23799bcda0 --- /dev/null +++ b/.claude/rules/sim-components.md @@ -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 diff --git a/.claude/rules/sim-hooks.md b/.claude/rules/sim-hooks.md new file mode 100644 index 0000000000..3c06a4a310 --- /dev/null +++ b/.claude/rules/sim-hooks.md @@ -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(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 diff --git a/.claude/rules/sim-imports.md b/.claude/rules/sim-imports.md new file mode 100644 index 0000000000..b1f1926cd9 --- /dev/null +++ b/.claude/rules/sim-imports.md @@ -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' +``` diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md new file mode 100644 index 0000000000..cef0c895bd --- /dev/null +++ b/.claude/rules/sim-integrations.md @@ -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) { + return ( + + {/* SVG path from service's brand assets */} + + ) +} +``` + +## 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` diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md new file mode 100644 index 0000000000..0ca91ac263 --- /dev/null +++ b/.claude/rules/sim-queries.md @@ -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) diff --git a/.claude/rules/sim-stores.md b/.claude/rules/sim-stores.md new file mode 100644 index 0000000000..333ff9fd91 --- /dev/null +++ b/.claude/rules/sim-stores.md @@ -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()( + 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()( + 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 }) +``` diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md new file mode 100644 index 0000000000..1b8c384a70 --- /dev/null +++ b/.claude/rules/sim-styling.md @@ -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' + +
+``` + +## 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 +
) } - // 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 (
{ + if (!message.contentBlocks || message.contentBlocks.length === 0) return false + return message.contentBlocks.some((block) => { + if (block.type === 'text') { + const parsed = parseSpecialTags(block.content) + return parsed.cleanContent.trim().length > 0 + } + return block.type === 'thinking' || block.type === 'tool_call' + }) + }, [message.contentBlocks]) + + return { + hasVisibleContent, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-message-editing.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-message-editing.ts index bdef4401b3..940501ac6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-message-editing.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-message-editing.ts @@ -2,11 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import type { CopilotMessage } from '@/stores/panel' +import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel' import { useCopilotStore } from '@/stores/panel' const logger = createLogger('useMessageEditing') +/** + * Ref interface for UserInput component + */ +interface UserInputRef { + focus: () => void +} + /** * Message truncation height in pixels */ @@ -32,8 +39,8 @@ interface UseMessageEditingProps { setShowCheckpointDiscardModal: (show: boolean) => void pendingEditRef: React.MutableRefObject<{ message: string - fileAttachments?: any[] - contexts?: any[] + fileAttachments?: MessageFileAttachment[] + contexts?: ChatContext[] } | null> /** * When true, disables the internal document click-outside handler. @@ -69,7 +76,7 @@ export function useMessageEditing(props: UseMessageEditingProps) { const editContainerRef = useRef(null) const messageContentRef = useRef(null) - const userInputRef = useRef(null) + const userInputRef = useRef(null) const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore() @@ -121,7 +128,11 @@ export function useMessageEditing(props: UseMessageEditingProps) { * Truncates messages after edited message and resends with same ID */ const performEdit = useCallback( - async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => { + async ( + editedMessage: string, + fileAttachments?: MessageFileAttachment[], + contexts?: ChatContext[] + ) => { const currentMessages = messages const editIndex = currentMessages.findIndex((m) => m.id === message.id) @@ -134,7 +145,7 @@ export function useMessageEditing(props: UseMessageEditingProps) { ...message, content: editedMessage, fileAttachments: fileAttachments || message.fileAttachments, - contexts: contexts || (message as any).contexts, + contexts: contexts || message.contexts, } useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] }) @@ -153,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) { timestamp: m.timestamp, ...(m.contentBlocks && { contentBlocks: m.contentBlocks }), ...(m.fileAttachments && { fileAttachments: m.fileAttachments }), - ...((m as any).contexts && { contexts: (m as any).contexts }), + ...(m.contexts && { contexts: m.contexts }), })), }), }) @@ -164,7 +175,7 @@ export function useMessageEditing(props: UseMessageEditingProps) { await sendMessage(editedMessage, { fileAttachments: fileAttachments || message.fileAttachments, - contexts: contexts || (message as any).contexts, + contexts: contexts || message.contexts, messageId: message.id, queueIfBusy: false, }) @@ -178,7 +189,11 @@ export function useMessageEditing(props: UseMessageEditingProps) { * Checks for checkpoints and shows confirmation if needed */ const handleSubmitEdit = useCallback( - async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => { + async ( + editedMessage: string, + fileAttachments?: MessageFileAttachment[], + contexts?: ChatContext[] + ) => { if (!editedMessage.trim()) return if (isSendingMessage) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/index.ts new file mode 100644 index 0000000000..d2cf90344a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/index.ts @@ -0,0 +1 @@ +export * from './copilot-message' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts index 3ac19aac2d..632dae1424 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/index.ts new file mode 100644 index 0000000000..fb80d1dda5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/index.ts @@ -0,0 +1 @@ +export * from './plan-mode-section' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx index cadc5f1e89..c4f17704ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/index.ts new file mode 100644 index 0000000000..498f56dfb9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/index.ts @@ -0,0 +1 @@ +export * from './queued-messages' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/index.ts new file mode 100644 index 0000000000..4f6ca98522 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/index.ts @@ -0,0 +1 @@ +export * from './todo-list' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/index.ts new file mode 100644 index 0000000000..0269fca394 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/index.ts @@ -0,0 +1 @@ +export * from './tool-call' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 826085970d..c0ce75b3ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/attached-files-display/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/attached-files-display/index.ts new file mode 100644 index 0000000000..ef4e374119 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/attached-files-display/index.ts @@ -0,0 +1 @@ +export * from './attached-files-display' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/bottom-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/bottom-controls.tsx new file mode 100644 index 0000000000..f01b583c84 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/bottom-controls.tsx @@ -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 ( +
+ {/* Left side: Mode Selector + Model Selector */} +
+ {!hideModeSelector && ( + + )} + + +
+ + {/* Right side: Attach Button + Send Button */} +
+ + + + + {showAbortButton ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/index.ts new file mode 100644 index 0000000000..7a0d61937f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/bottom-controls/index.ts @@ -0,0 +1 @@ +export * from './bottom-controls' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-pills/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-pills/index.ts new file mode 100644 index 0000000000..09b0963428 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-pills/index.ts @@ -0,0 +1 @@ +export * from './context-pills' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts index 1d0da42d4c..acea9d9b2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/index.ts new file mode 100644 index 0000000000..9a6118aeea --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/index.ts @@ -0,0 +1 @@ +export * from './mention-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/index.ts new file mode 100644 index 0000000000..119be98587 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/index.ts @@ -0,0 +1 @@ +export * from './mode-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/index.ts new file mode 100644 index 0000000000..9bd04ddd98 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/index.ts @@ -0,0 +1 @@ +export * from './model-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/index.ts new file mode 100644 index 0000000000..7b7f088caa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/index.ts @@ -0,0 +1 @@ +export * from './slash-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts index 858a39c136..af44a7a4e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts @@ -5,5 +5,6 @@ export { useMentionData } from './use-mention-data' export { useMentionInsertHandlers } from './use-mention-insert-handlers' export { useMentionKeyboard } from './use-mention-keyboard' export { useMentionMenu } from './use-mention-menu' +export { useMentionSystem } from './use-mention-system' export { useMentionTokens } from './use-mention-tokens' export { useTextareaAutoResize } from './use-textarea-auto-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 90b5d6bc99..819d0ef5d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -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(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(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-system.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-system.ts new file mode 100644 index 0000000000..d67a1d22c7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-system.ts @@ -0,0 +1,107 @@ +import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' +import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management' +import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' +import { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' +import { useMentionInsertHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers' +import { useMentionKeyboard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard' +import { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu' +import { useMentionTokens } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens' +import { useTextareaAutoResize } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize' +import type { ChatContext } from '@/stores/panel' + +interface UseMentionSystemProps { + message: string + setMessage: (message: string) => void + workflowId: string | null + workspaceId: string + userId?: string + panelWidth: number + disabled: boolean + isLoading: boolean + inputContainerRef: HTMLDivElement | null + initialContexts?: ChatContext[] + mentionFolderNav: MentionFolderNav | null +} + +/** + * Composite hook that combines all mention-related hooks into a single interface. + * Reduces import complexity in components that need full mention functionality. + * + * @param props - Configuration for all mention system hooks + * @returns Combined interface for mention system functionality + */ +export function useMentionSystem({ + message, + setMessage, + workflowId, + workspaceId, + userId, + panelWidth, + disabled, + isLoading, + inputContainerRef, + initialContexts, + mentionFolderNav, +}: UseMentionSystemProps) { + const contextManagement = useContextManagement({ message, initialContexts }) + + const mentionMenu = useMentionMenu({ + message, + selectedContexts: contextManagement.selectedContexts, + onContextSelect: contextManagement.addContext, + onMessageChange: setMessage, + }) + + const mentionTokens = useMentionTokens({ + message, + selectedContexts: contextManagement.selectedContexts, + mentionMenu, + setMessage, + setSelectedContexts: contextManagement.setSelectedContexts, + }) + + const { overlayRef } = useTextareaAutoResize({ + message, + panelWidth, + selectedContexts: contextManagement.selectedContexts, + textareaRef: mentionMenu.textareaRef, + containerRef: inputContainerRef, + }) + + const mentionData = useMentionData({ + workflowId, + workspaceId, + }) + + const fileAttachments = useFileAttachments({ + userId, + disabled, + isLoading, + }) + + const insertHandlers = useMentionInsertHandlers({ + mentionMenu, + workflowId, + selectedContexts: contextManagement.selectedContexts, + onContextAdd: contextManagement.addContext, + mentionFolderNav, + }) + + const mentionKeyboard = useMentionKeyboard({ + mentionMenu, + mentionData, + insertHandlers, + mentionFolderNav, + }) + + return { + contextManagement, + mentionMenu, + mentionTokens, + overlayRef, + mentionData, + fileAttachments, + insertHandlers, + mentionKeyboard, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/index.ts new file mode 100644 index 0000000000..20b5364158 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/index.ts @@ -0,0 +1 @@ +export * from './user-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 3636c6b29b..ea340ce670 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -9,19 +9,19 @@ import { useState, } from 'react' import { createLogger } from '@sim/logger' -import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react' +import { AtSign } from 'lucide-react' import { useParams } from 'next/navigation' import { createPortal } from 'react-dom' import { Badge, Button, Textarea } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' +import type { CopilotModelId } from '@/lib/copilot/models' import { cn } from '@/lib/core/utils/cn' import { AttachedFilesDisplay, + BottomControls, ContextPills, type MentionFolderNav, MentionMenu, - ModelSelector, - ModeSelector, type SlashFolderNav, SlashMenu, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' @@ -44,6 +44,10 @@ import { useTextareaAutoResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' +import { + computeMentionHighlightRanges, + extractContextTokens, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' import { useCopilotStore } from '@/stores/panel' @@ -306,7 +310,7 @@ const UserInput = forwardRef( size: f.size, })) - onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any) + onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts) const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage if (shouldClearInput) { @@ -657,7 +661,7 @@ const UserInput = forwardRef( const handleModelSelect = useCallback( (model: string) => { - setSelectedModel(model as any) + setSelectedModel(model as CopilotModelId) }, [setSelectedModel] ) @@ -677,15 +681,17 @@ const UserInput = forwardRef( return {displayText} } - 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 {displayText} } + const elements: React.ReactNode[] = [] let lastIndex = 0 + for (let i = 0; i < ranges.length; i++) { const range = ranges[i] @@ -694,13 +700,12 @@ const UserInput = forwardRef( elements.push({before}) } - const mentionText = message.slice(range.start, range.end) elements.push( - {mentionText} + {range.token} ) lastIndex = range.end @@ -713,7 +718,7 @@ const UserInput = forwardRef( } return elements.length > 0 ? elements : {'\u00A0'} - }, [message, contextManagement.selectedContexts, mentionTokensWithContext]) + }, [message, contextManagement.selectedContexts]) return (
(
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */} -
- {/* Left side: Mode Selector + Model Selector */} -
- {!hideModeSelector && ( - - )} - - -
- - {/* Right side: Attach Button + Send Button */} -
- - - - - {showAbortButton ? ( - - ) : ( - - )} -
-
+ void handleSubmit()} + onAbort={handleAbort} + onFileSelect={fileAttachments.handleFileSelect} + /> {/* Hidden File Input - enabled during streaming so users can prepare images for the next message */} 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. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/index.ts new file mode 100644 index 0000000000..425106f698 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/index.ts @@ -0,0 +1 @@ +export * from './welcome' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 03053ccf3d..19aeadd243 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -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(({ panelWidth }, ref const copilotContainerRef = useRef(null) const cancelEditCallbackRef = useRef<(() => void) | null>(null) const [editingMessageId, setEditingMessageId] = useState(null) - const [isEditingMessage, setIsEditingMessage] = useState(false) const [revertingMessageId, setRevertingMessageId] = useState(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(({ panelWidth }, ref areChatsFresh, workflowId: copilotWorkflowId, setPlanTodos, + closePlanTodos, clearPlanArtifact, savePlanArtifact, - setSelectedModel, loadAutoAllowedTools, } = useCopilotStore() @@ -292,6 +296,15 @@ export const Copilot = forwardRef(({ panelWidth }, ref } }, [abortMessage, showPlanTodos]) + /** + * Handles closing the plan todos section + * Calls store action and clears the todos + */ + const handleClosePlanTodos = useCallback(() => { + closePlanTodos() + setPlanTodos([]) + }, [closePlanTodos, setPlanTodos]) + /** * Handles message submission to the copilot * @param query - The message text to send @@ -299,13 +312,12 @@ export const Copilot = forwardRef(({ panelWidth }, ref * @param contexts - Optional context references */ const handleSubmit = useCallback( - async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => { + async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => { // Allow submission even when isSendingMessage - store will queue the message if (!query || !activeWorkflowId) return if (showPlanTodos) { - const store = useCopilotStore.getState() - store.setPlanTodos([]) + setPlanTodos([]) } try { @@ -319,7 +331,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref logger.error('Failed to send message:', error) } }, - [activeWorkflowId, sendMessage, showPlanTodos] + [activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos] ) /** @@ -330,7 +342,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref const handleEditModeChange = useCallback( (messageId: string, isEditing: boolean, cancelCallback?: () => void) => { setEditingMessageId(isEditing ? messageId : null) - setIsEditingMessage(isEditing) cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing }) }, @@ -375,24 +386,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref [handleHistoryDropdownOpenHook] ) - /** - * Skeleton loading component for chat history - */ - const ChatHistorySkeleton = () => ( - <> - -
- -
- {[1, 2, 3].map((i) => ( -
-
-
- ))} -
- - ) - return ( <>
(({ panelWidth }, ref { - const store = useCopilotStore.getState() - store.closePlanTodos?.() - useCopilotStore.setState({ planTodos: [] }) - }} + onClose={handleClosePlanTodos} />
)} diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index d9cd0aa3e7..3a3e8ed11f 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1736,8 +1736,13 @@ const sseHandlers: Record = { } }, 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 } }, From ba9fc331163eb87d040294e1b983d40bd6f9705d Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 19 Jan 2026 14:35:03 -0800 Subject: [PATCH 3/9] fix(copilot): options selection strikethrough --- .../copilot-message/copilot-message.tsx | 27 +++++++++++++++++++ .../components/tool-call/tool-call.tsx | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 9ee41bc155..de8f935180 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -183,6 +183,32 @@ const CopilotMessage: FC = memo( return message.content ? parseSpecialTags(message.content) : null }, [message.content, message.contentBlocks, isUser, isStreaming]) + // Detect previously selected option by checking if the next user message matches an option + const selectedOptionKey = useMemo(() => { + if (!parsedTags?.options || isStreaming) return null + + // Find the index of this message in the messages array + const currentIndex = messages.findIndex((m) => m.id === message.id) + if (currentIndex === -1 || currentIndex >= messages.length - 1) return null + + // Get the next message + const nextMessage = messages[currentIndex + 1] + if (!nextMessage || nextMessage.role !== 'user') return null + + const nextContent = nextMessage.content?.trim() + if (!nextContent) return null + + // Check if the next user message content matches any option title + for (const [key, option] of Object.entries(parsedTags.options)) { + const optionTitle = typeof option === 'string' ? option : option.title + if (nextContent === optionTitle) { + return key + } + } + + return null + }, [parsedTags?.options, messages, message.id, isStreaming]) + // Get sendMessage from store for continuation actions const sendMessage = useCopilotStore((s) => s.sendMessage) @@ -448,6 +474,7 @@ const CopilotMessage: FC = memo( isLastMessage && !isStreaming && parsedTags.optionsComplete === true } streaming={isStreaming || !parsedTags.optionsComplete} + selectedOptionKey={selectedOptionKey} /> )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index c0ce75b3ae..0619b76a67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -291,6 +291,7 @@ export function OptionsSelector({ disabled = false, enableKeyboardNav = false, streaming = false, + selectedOptionKey = null, }: { options: Record onSelect: (optionKey: string, optionText: string) => void @@ -299,6 +300,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(() => { @@ -317,7 +320,7 @@ export function OptionsSelector({ }, [options]) const [hoveredIndex, setHoveredIndex] = useState(0) - const [chosenKey, setChosenKey] = useState(null) + const [chosenKey, setChosenKey] = useState(selectedOptionKey) const containerRef = useRef(null) const isLocked = chosenKey !== null From ec27e4ad107b31ada9dc9cf44b9f0db0fd6bd8c8 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 19 Jan 2026 14:37:58 -0800 Subject: [PATCH 4/9] fix(copilot): options render inside thinking --- .../components/thinking-block/thinking-block.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx index 616dbe0760..866c5eb103 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx @@ -7,11 +7,16 @@ import { CopilotMarkdownRenderer } from '../markdown-renderer' /** * Removes thinking tags (raw or escaped) from streamed content. + * Also strips special tags (options, plan) that may have been accidentally included. */ function stripThinkingTags(text: string): string { return text .replace(/<\/?thinking[^>]*>/gi, '') .replace(/<\/?thinking[^&]*>/gi, '') + .replace(/[\s\S]*?<\/options>/gi, '') // Strip complete options tags + .replace(/[\s\S]*$/gi, '') // Strip incomplete/streaming options tags + .replace(/[\s\S]*?<\/plan>/gi, '') // Strip complete plan tags + .replace(/[\s\S]*$/gi, '') // Strip incomplete/streaming plan tags .trim() } From 81059dcad45da3fc384c40603576111b129be284 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 19 Jan 2026 19:01:54 -0800 Subject: [PATCH 5/9] fix(copilot): checkpoints, user-input; improvement(code): colors --- .../checkpoint-confirmation.tsx | 79 +++++++ .../checkpoint-confirmation/index.ts | 1 + .../checkpoint-discard-modal.tsx | 56 ----- .../checkpoint-discard-modal/index.ts | 1 - .../copilot-message/components/index.ts | 3 +- .../restore-checkpoint-modal/index.ts | 1 - .../restore-checkpoint-modal.tsx | 46 ---- .../smooth-streaming/smooth-streaming.tsx | 32 +-- .../thinking-block/thinking-block.tsx | 86 ++----- .../copilot-message/copilot-message.tsx | 128 +++-------- .../hooks/use-checkpoint-management.ts | 71 +++--- .../hooks/use-message-editing.ts | 50 +---- .../components/tool-call/tool-call.tsx | 196 ++++++++-------- .../components/user-input/user-input.tsx | 1 - .../copilot/components/welcome/welcome.tsx | 12 +- .../panel/components/copilot/copilot.tsx | 85 ++----- .../copilot/hooks/use-chat-history.ts | 11 +- .../hooks/use-copilot-initialization.ts | 22 +- .../copilot/hooks/use-todo-management.ts | 8 +- .../components/terminal/terminal.tsx | 28 +-- .../workflow-block/workflow-block.tsx | 56 +++++ .../hooks/use-scroll-management.ts | 209 +++++++----------- .../components/emcn/components/code/code.css | 36 +-- 23 files changed, 463 insertions(+), 755 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/checkpoint-confirmation.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/checkpoint-discard-modal.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/restore-checkpoint-modal.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/checkpoint-confirmation.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/checkpoint-confirmation.tsx new file mode 100644 index 0000000000..495eb5ee5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/checkpoint-confirmation.tsx @@ -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 ( +
+

+ {isRestoreVariant ? ( + <> + Revert to checkpoint? This will restore your workflow to the state saved at this + checkpoint.{' '} + This action cannot be undone. + + ) : ( + 'Continue from a previous message?' + )} +

+
+ + + {!isRestoreVariant && onContinue && ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/index.ts new file mode 100644 index 0000000000..612120a4f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-confirmation/index.ts @@ -0,0 +1 @@ +export * from './checkpoint-confirmation' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/checkpoint-discard-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/checkpoint-discard-modal.tsx deleted file mode 100644 index 18dc0cac8b..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/checkpoint-discard-modal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Button } from '@/components/emcn' - -interface CheckpointDiscardModalProps { - isProcessingDiscard: boolean - onCancel: () => void - onRevert: () => void - onContinue: () => void -} - -/** - * Inline confirmation modal for discarding checkpoints during message editing - * Shows options to cancel, revert to checkpoint, or continue without reverting - */ -export function CheckpointDiscardModal({ - isProcessingDiscard, - onCancel, - onRevert, - onContinue, -}: CheckpointDiscardModalProps) { - return ( -
-

- Continue from a previous message? -

-
- - - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/index.ts deleted file mode 100644 index b90d263c08..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/checkpoint-discard-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './checkpoint-discard-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/index.ts index cdd4730023..96b6244e92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/index.ts @@ -1,7 +1,6 @@ -export * from './checkpoint-discard-modal' +export * from './checkpoint-confirmation' export * from './file-display' export { CopilotMarkdownRenderer } from './markdown-renderer' -export * from './restore-checkpoint-modal' export * from './smooth-streaming' export * from './thinking-block' export * from './usage-limit-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/index.ts deleted file mode 100644 index 7ad1c23c04..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './restore-checkpoint-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/restore-checkpoint-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/restore-checkpoint-modal.tsx deleted file mode 100644 index 766de5518c..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/restore-checkpoint-modal/restore-checkpoint-modal.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Button } from '@/components/emcn' - -interface RestoreCheckpointModalProps { - isReverting: boolean - onCancel: () => void - onConfirm: () => void -} - -/** - * Inline confirmation modal for restoring a checkpoint - * Warns user that the action cannot be undone - */ -export function RestoreCheckpointModal({ - isReverting, - onCancel, - onConfirm, -}: RestoreCheckpointModalProps) { - return ( -
-

- Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '} - This action cannot be undone. -

-
- - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx index 41883e606b..c0965808e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx @@ -2,26 +2,16 @@ import { memo, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/core/utils/cn' 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) => (
@@ -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(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 ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx index 866c5eb103..be700c6a18 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx @@ -5,69 +5,51 @@ import clsx from 'clsx' import { ChevronUp } from 'lucide-react' import { CopilotMarkdownRenderer } from '../markdown-renderer' -/** - * Removes thinking tags (raw or escaped) from streamed content. - * Also strips special tags (options, plan) that may have been accidentally included. - */ +/** Removes thinking tags (raw or escaped) and special tags from streamed content */ function stripThinkingTags(text: string): string { return text .replace(/<\/?thinking[^>]*>/gi, '') .replace(/<\/?thinking[^&]*>/gi, '') - .replace(/[\s\S]*?<\/options>/gi, '') // Strip complete options tags - .replace(/[\s\S]*$/gi, '') // Strip incomplete/streaming options tags - .replace(/[\s\S]*?<\/plan>/gi, '') // Strip complete plan tags - .replace(/[\s\S]*$/gi, '') // Strip incomplete/streaming plan tags + .replace(/[\s\S]*?<\/options>/gi, '') + .replace(/[\s\S]*$/gi, '') + .replace(/[\s\S]*?<\/plan>/gi, '') + .replace(/[\s\S]*$/gi, '') .trim() } -/** - * Max height for thinking content before internal scrolling kicks in - */ +/** Max height for thinking content before internal scrolling */ const THINKING_MAX_HEIGHT = 150 -/** - * Height threshold before gradient fade kicks in - */ +/** 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. + * Uses gradient fade at top when content is tall enough. */ 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(null) const rafRef = useRef(null) - // Initialize index based on streaming state const indexRef = useRef(isStreaming ? 0 : content.length) const lastFrameTimeRef = useRef(0) const isAnimatingRef = useRef(false) @@ -93,7 +75,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 @@ -115,7 +96,6 @@ const SmoothThinkingText = memo( rafRef.current = requestAnimationFrame(animateText) } } else { - // Streaming ended - show full content immediately if (rafRef.current) { cancelAnimationFrame(rafRef.current) } @@ -132,7 +112,6 @@ const SmoothThinkingText = memo( } }, [content, isStreaming]) - // Check if content height exceeds threshold for gradient useEffect(() => { if (textRef.current && isStreaming) { const height = textRef.current.scrollHeight @@ -142,7 +121,6 @@ const SmoothThinkingText = memo( } }, [displayedContent, isStreaming]) - // Apply vertical gradient fade at the top only when content is tall enough const gradientStyle = isStreaming && showGradient ? { @@ -170,9 +148,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 @@ -187,13 +163,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, @@ -202,7 +173,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) @@ -214,12 +184,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 @@ -232,7 +198,6 @@ export function ThinkingBlock({ } }, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags]) - // Reset start time when streaming begins useEffect(() => { if (isStreaming && !hasFollowingContent) { startTimeRef.current = Date.now() @@ -241,9 +206,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(() => { @@ -253,7 +216,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 @@ -272,7 +234,6 @@ export function ThinkingBlock({ setUserHasScrolledAway(true) } - // Re-stick if user scrolls back to bottom with intent if (userHasScrolledAway && isNearBottom && delta > 10) { setUserHasScrolledAway(false) } @@ -286,7 +247,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 @@ -307,20 +267,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` @@ -328,11 +284,9 @@ export function ThinkingBlock({ } const streamingLabel = getStreamingLabel(label) - // During streaming: show header with shimmer effect + expanded content if (!isThinkingDone) { return (
- {/* Define shimmer keyframes */}