From 9865592231e84b16c28c9cd62dd10d7ac932d2ce Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 14 Oct 2025 09:47:02 -0700 Subject: [PATCH 01/28] Add exa to search online tool --- .../tools/server/other/search-online.ts | 67 +++++++++++++++++-- apps/sim/lib/env.ts | 1 + 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 9169e9e4c4a..ea28794fa3d 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -18,17 +18,75 @@ export const searchOnlineServerTool: BaseServerTool = { const { query, num = 10, type = 'search', gl, hl } = params if (!query || typeof query !== 'string') throw new Error('query is required') - // Input diagnostics (no secrets) - const hasApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) - logger.info('Performing online search (new runtime)', { + // Check which API keys are available + const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0) + const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) + + logger.info('Performing online search', { queryLength: query.length, num, type, gl, hl, - hasApiKey, + hasExaApiKey, + hasSerperApiKey, }) + // Try Exa first if available + if (hasExaApiKey) { + try { + logger.debug('Attempting exa_search', { num }) + const exaResult = await executeTool('exa_search', { + query, + numResults: num, + type: 'auto', + apiKey: env.EXA_API_KEY || '', + }) + + const exaResults = (exaResult as any)?.output?.results || [] + const count = Array.isArray(exaResults) ? exaResults.length : 0 + const firstTitle = count > 0 ? String(exaResults[0]?.title || '') : undefined + + logger.info('exa_search completed', { + success: exaResult.success, + resultsCount: count, + firstTitlePreview: firstTitle?.slice(0, 120), + }) + + if (exaResult.success && count > 0) { + // Transform Exa results to match expected format + const transformedResults = exaResults.map((result: any) => ({ + title: result.title || '', + link: result.url || '', + snippet: result.text || result.summary || '', + date: result.publishedDate, + position: exaResults.indexOf(result) + 1, + })) + + return { + results: transformedResults, + query, + type, + totalResults: count, + source: 'exa', + } + } + + logger.warn('exa_search returned no results, falling back to Serper', { + queryLength: query.length, + }) + } catch (exaError: any) { + logger.warn('exa_search failed, falling back to Serper', { + error: exaError?.message, + }) + } + } + + // Fall back to Serper if Exa failed or wasn't available + if (!hasSerperApiKey) { + throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)') + } + const toolParams = { query, num, @@ -65,6 +123,7 @@ export const searchOnlineServerTool: BaseServerTool = { query, type, totalResults: count, + source: 'serper', } } catch (e: any) { logger.error('search_online execution error', { message: e?.message }) diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index eb3ebc8526f..5baedfbf737 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -75,6 +75,7 @@ export const env = createEnv({ OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search + EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint From 732b61369d2f58d7b6ca0476db67157ca5955f76 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 13:14:55 -0700 Subject: [PATCH 02/28] Larger font size --- .../copilot-message/copilot-message.tsx | 302 +++++++++++++----- .../components/user-input/user-input.tsx | 65 +++- .../panel/components/copilot/copilot.tsx | 4 +- apps/sim/stores/copilot/store.ts | 9 +- apps/sim/stores/copilot/types.ts | 1 + 5 files changed, 295 insertions(+), 86 deletions(-) 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 2aaf4494c1f..1fbca40a3b0 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 @@ -1,6 +1,6 @@ 'use client' -import { type FC, memo, useEffect, useMemo, useState } from 'react' +import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react' import { Blocks, BookOpen, @@ -8,6 +8,7 @@ import { Box, Check, Clipboard, + Edit, Info, LibraryBig, Loader2, @@ -32,6 +33,7 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types' +import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' const logger = createLogger('CopilotMessage') @@ -49,6 +51,13 @@ const CopilotMessage: FC = memo( const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false) const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) const [showAllContexts, setShowAllContexts] = useState(false) + const [isEditMode, setIsEditMode] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [editedContent, setEditedContent] = useState(message.content) + const [isHoveringMessage, setIsHoveringMessage] = useState(false) + const editContainerRef = useRef(null) + const messageContentRef = useRef(null) + const [needsExpansion, setNeedsExpansion] = useState(false) // Get checkpoint functionality from copilot store const { @@ -58,6 +67,11 @@ const CopilotMessage: FC = memo( currentChat, messages, workflowId, + sendMessage, + isSendingMessage, + abortMessage, + mode, + setMode, } = useCopilotStore() // Get preview store for accessing workflow YAML after rejection @@ -70,6 +84,13 @@ const CopilotMessage: FC = memo( const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : [] const hasCheckpoints = messageCheckpoints.length > 0 + // 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]) + const handleCopyContent = () => { // Copy clean text content navigator.clipboard.writeText(message.content) @@ -258,6 +279,83 @@ const CopilotMessage: FC = memo( setShowRestoreConfirmation(false) } + const handleEditMessage = () => { + setIsEditMode(true) + setIsExpanded(false) + setEditedContent(message.content) + } + + const handleCancelEdit = () => { + setIsEditMode(false) + setEditedContent(message.content) + } + + const handleMessageClick = () => { + if (isSendingMessage) return + + // If message needs expansion and is not expanded, expand it first + if (needsExpansion && !isExpanded) { + setIsExpanded(true) + } else { + // Otherwise enter edit mode + handleEditMessage() + } + } + + const handleSubmitEdit = async ( + editedMessage: string, + fileAttachments?: any[], + contexts?: any[] + ) => { + if (!editedMessage.trim() || isSendingMessage) return + + // Find the index of this message and truncate conversation + const currentMessages = messages + const editIndex = currentMessages.findIndex((m) => m.id === message.id) + + if (editIndex !== -1) { + // Exit edit mode immediately + setIsEditMode(false) + + // Truncate messages after the edited message (remove it and everything after) + const truncatedMessages = currentMessages.slice(0, editIndex) + + // Update store to show only messages before the edit point + useCopilotStore.setState({ messages: truncatedMessages }) + + // If we have a current chat, update the DB to remove messages after this point + if (currentChat?.id) { + try { + await fetch('/api/copilot/chat/update-messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: currentChat.id, + messages: truncatedMessages.map(m => ({ + id: m.id, + role: m.role, + content: m.content, + timestamp: m.timestamp, + ...(m.contentBlocks && { contentBlocks: m.contentBlocks }), + ...(m.fileAttachments && { fileAttachments: m.fileAttachments }), + ...((m as any).contexts && { contexts: (m as any).contexts }), + })) + }), + }) + } catch (error) { + logger.error('Failed to update messages in DB after edit:', error) + } + } + + // Send the edited message with the SAME message ID + await sendMessage(editedMessage, { + fileAttachments: fileAttachments || message.fileAttachments, + contexts: contexts || (message as any).contexts, + messageId: message.id // Reuse the original message ID + }) + } + } + useEffect(() => { if (showCopySuccess) { const timer = setTimeout(() => { @@ -285,6 +383,56 @@ const CopilotMessage: FC = memo( } }, [showDownvoteSuccess]) + // Handle click outside to exit edit mode + useEffect(() => { + if (!isEditMode) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + + // Don't close if clicking inside the edit container + if (editContainerRef.current && editContainerRef.current.contains(target)) { + return + } + + // Check if clicking on another user message box + const clickedMessageBox = target.closest('[data-message-box]') as HTMLElement + if (clickedMessageBox) { + const clickedMessageId = clickedMessageBox.getAttribute('data-message-id') + // If clicking on a different message, close this one (the other will open via its own click handler) + if (clickedMessageId && clickedMessageId !== message.id) { + handleCancelEdit() + } + // Don't close if clicking on the same message (already in edit mode) + return + } + + // Close edit mode if clicking anywhere else (not a message box) + handleCancelEdit() + } + + // Use click event instead of mousedown to allow the target's click handler to fire first + // Add listener with a slight delay to avoid immediate trigger when entering edit mode + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside, true) // Use capture phase + }, 100) + + return () => { + clearTimeout(timeoutId) + document.removeEventListener('click', handleClickOutside, true) + } + }, [isEditMode, message.id]) + + // Check if message content needs expansion (is tall) + useEffect(() => { + if (messageContentRef.current && isUser) { + const scrollHeight = messageContentRef.current.scrollHeight + const clientHeight = messageContentRef.current.clientHeight + // If content is taller than the max height (3 lines ~60px), mark as needing expansion + setNeedsExpansion(scrollHeight > 60) + } + }, [message.content, isUser]) + // Get clean text content with double newline parsing const cleanTextContent = useMemo(() => { if (!message.content) return '' @@ -365,23 +513,35 @@ const CopilotMessage: FC = memo( if (isUser) { return ( -
- {/* File attachments displayed above the message, completely separate from message box width */} - {message.fileAttachments && message.fileAttachments.length > 0 && ( -
-
- -
+
+ {isEditMode ? ( +
+
- )} + ) : ( +
+ {/* File attachments displayed above the message box */} + {message.fileAttachments && message.fileAttachments.length > 0 && ( +
+ +
+ )} - {/* Context chips displayed above the message bubble, independent of inline text */} - {(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) || - (Array.isArray(message.contentBlocks) && - (message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? ( -
-
-
+ {/* Context chips displayed above the message box */} + {(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) || + (Array.isArray(message.contentBlocks) && + (message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? ( +
{(() => { const direct = Array.isArray((message as any).contexts) ? ((message as any).contexts as any[]) @@ -451,21 +611,26 @@ const CopilotMessage: FC = memo( ) })()}
-
-
- ) : null} + ) : null} -
-
- {/* Message content in purple box */} + {/* Message box - styled like input, clickable to edit */}
setIsHoveringMessage(true)} + onMouseLeave={() => setIsHoveringMessage(false)} + className='group relative cursor-text rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 shadow-xs transition-all duration-200 hover:border-[#D0D0D0] dark:border-[#414141] dark:bg-[var(--surface-elevated)] dark:hover:border-[#525252]' > -
+
{(() => { const text = message.content || '' const contexts: any[] = Array.isArray((message as any).contexts) @@ -475,7 +640,7 @@ const CopilotMessage: FC = memo( .filter((c) => c?.kind !== 'current_workflow') .map((c) => c?.label) .filter(Boolean) as string[] - if (!labels.length) return + if (!labels.length) return text const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g') @@ -502,60 +667,53 @@ const CopilotMessage: FC = memo( if (tail) nodes.push(tail) return nodes })()} + + {/* Gradient fade and ellipsis when truncated */} + {!isExpanded && needsExpansion && ( +
+ )}
-
- {hasCheckpoints && ( -
- {showRestoreConfirmation ? ( -
- Restore Checkpoint? - - -
- ) : ( + + {/* Show more indicator when truncated */} + {!isExpanded && needsExpansion && ( +
+ Click to expand... +
+ )} + + {/* Abort button when hovering and response is generating (only on last user message) */} + {isSendingMessage && isHoveringMessage && isLastUserMessage && ( +
- )} -
- )} +
+ )} + + {/* Edit indicator on hover (only when not generating) */} + {!isSendingMessage && ( +
+ +
+ )} +
-
+ )}
) } if (isAssistant) { return ( -
-
+
+
{/* Content blocks in chronological order */} {memoizedContentBlocks} 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 d2a98dc7488..799bfe13471 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 @@ -20,6 +20,7 @@ import { BrainCircuit, Check, ChevronRight, + Edit, FileText, Image, Infinity as InfinityIcon, @@ -54,7 +55,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useCopilotStore } from '@/stores/copilot/store' import type { ChatContext } from '@/stores/copilot/types' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ContextUsagePill } from '../context-usage-pill/context-usage-pill' const logger = createLogger('CopilotUserInput') @@ -95,6 +96,7 @@ interface UserInputProps { value?: string // Controlled value from outside onChange?: (value: string) => void // Callback when value changes panelWidth?: number // Panel width to adjust truncation + hideContextUsage?: boolean // Hide the context usage pill } interface UserInputRef { @@ -116,6 +118,7 @@ const UserInput = forwardRef( value: controlledValue, onChange: onControlledChange, panelWidth = 308, + hideContextUsage = false, }, ref ) => { @@ -1919,11 +1922,45 @@ const UserInput = forwardRef( setOpenSubmenuFor(null) } + useEffect(() => { + const textarea = textareaRef.current + const overlay = overlayRef.current + if (!textarea || !overlay || typeof window === 'undefined') return + + const syncOverlayStyles = () => { + const styles = window.getComputedStyle(textarea) + overlay.style.font = styles.font + overlay.style.letterSpacing = styles.letterSpacing + overlay.style.padding = styles.padding + overlay.style.lineHeight = styles.lineHeight + overlay.style.color = styles.color + overlay.style.whiteSpace = styles.whiteSpace + overlay.style.wordBreak = styles.wordBreak + overlay.style.width = `${textarea.clientWidth}px` + overlay.style.height = `${textarea.clientHeight}px` + overlay.style.borderRadius = styles.borderRadius + } + + syncOverlayStyles() + + let resizeObserver: ResizeObserver | null = null + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => syncOverlayStyles()) + resizeObserver.observe(textarea) + } + window.addEventListener('resize', syncOverlayStyles) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', syncOverlayStyles) + } + }, [panelWidth, message, selectedContexts]) + return ( -
+
( onDrop={handleDrop} > {/* Context Usage Pill - Top Right */} - {contextUsage && contextUsage.percentage > 0 && ( + {!hideContextUsage && contextUsage && contextUsage.percentage > 0 && (
( />
)} + {/* Attached Files Display with Thumbnails */} {attachedFiles.length > 0 && (
@@ -2054,9 +2092,9 @@ const UserInput = forwardRef( {/* Highlight overlay */}
-
+              
                 {(() => {
                   const elements: React.ReactNode[] = []
                   const remaining = message
@@ -2065,7 +2103,7 @@ const UserInput = forwardRef(
                   // Build regex for all labels
                   const labels = contexts.map((c) => c.label).filter(Boolean)
                   const pattern = new RegExp(
-                    `@(${labels.map((l) => l.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|')})`,
+                    `@(${labels.map((l) => l.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')).join('|')})`,
                     'g'
                   )
                   let lastIndex = 0
@@ -2075,11 +2113,14 @@ const UserInput = forwardRef(
                     const before = remaining.slice(lastIndex, i)
                     if (before) elements.push(before)
                     const mentionText = match[0]
-                    const mentionLabel = match[1]
                     elements.push(
                       
                         {mentionText}
                       
@@ -2099,6 +2140,12 @@ const UserInput = forwardRef(
               onKeyDown={handleKeyDown}
               onSelect={handleSelectAdjust}
               onMouseUp={handleSelectAdjust}
+              onScroll={(e) => {
+                if (overlayRef.current) {
+                  overlayRef.current.scrollTop = e.currentTarget.scrollTop
+                  overlayRef.current.scrollLeft = e.currentTarget.scrollLeft
+                }
+              }}
               placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
               disabled={disabled}
               rows={1}
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 2e9fed2a73f..13d7ffc3477 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
@@ -376,8 +376,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref
             ) : (
               
-
- {messages.length === 0 ? ( +
+ {messages.length === 0 && !isSendingMessage ? (
()( stream = true, fileAttachments, contexts, + messageId, } = options as { stream?: boolean fileAttachments?: MessageFileAttachment[] contexts?: ChatContext[] + messageId?: string } if (!workflowId) return const abortController = new AbortController() set({ isSendingMessage: true, error: null, abortController }) - const userMessage = createUserMessage(message, fileAttachments, contexts) + const userMessage = createUserMessage(message, fileAttachments, contexts, messageId) const streamingMessage = createStreamingMessage() let newMessages: CopilotMessage[] diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 48ee024bae8..cb875a756a4 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -156,6 +156,7 @@ export interface CopilotActions { stream?: boolean fileAttachments?: MessageFileAttachment[] contexts?: ChatContext[] + messageId?: string } ) => Promise abortMessage: () => void From ebbed53c33403f32bd15c6ece488f0bee165a796 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 14:56:15 -0700 Subject: [PATCH 03/28] Copilot UI improvements --- .../components/markdown-renderer.tsx | 22 +- .../components/thinking-block.tsx | 4 +- .../copilot-message/copilot-message.tsx | 39 +-- .../components/user-input/user-input.tsx | 232 ++++++++++++++++-- apps/sim/lib/copilot/inline-tool-call.tsx | 2 +- 5 files changed, 253 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx index 6cf689f6224..038b4315a6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -141,29 +141,29 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend () => ({ // Paragraph p: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), // Headings h1: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h2: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h3: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h4: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), @@ -171,7 +171,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Lists ul: ({ children }: React.HTMLAttributes) => (
    {children} @@ -179,7 +179,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend ), ol: ({ children }: React.HTMLAttributes) => (
      {children} @@ -190,7 +190,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend ordered, }: React.LiHTMLAttributes & { ordered?: boolean }) => (
    1. {children} @@ -321,7 +321,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Blockquotes blockquote: ({ children }: React.HTMLAttributes) => ( -
      +
      {children}
      ), @@ -339,7 +339,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Tables table: ({ children }: React.TableHTMLAttributes) => (
      - +
      {children}
      @@ -380,7 +380,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend ) return ( -
      +
      {content} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 2fbfa170971..bb6d6e8cda3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -79,7 +79,7 @@ export function ThinkingBlock({ }) }} className={cn( - 'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500', + 'mb-1 inline-flex items-center gap-1 text-gray-400 text-[11px] transition-colors hover:text-gray-500', 'font-normal italic' )} type='button' @@ -96,7 +96,7 @@ export function ThinkingBlock({ {isExpanded && (
      -
      +          
                   {content}
                   {isStreaming && (
                     
      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 1fbca40a3b0..e797d69c65d 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
      @@ -293,13 +293,13 @@ const CopilotMessage: FC = memo(
           const handleMessageClick = () => {
             if (isSendingMessage) return
             
      -      // If message needs expansion and is not expanded, expand it first
      +      // If message needs expansion and is not expanded, expand it
             if (needsExpansion && !isExpanded) {
               setIsExpanded(true)
      -      } else {
      -        // Otherwise enter edit mode
      -        handleEditMessage()
             }
      +      
      +      // Always enter edit mode on click
      +      handleEditMessage()
           }
       
           const handleSubmitEdit = async (
      @@ -403,23 +403,41 @@ const CopilotMessage: FC = memo(
                 if (clickedMessageId && clickedMessageId !== message.id) {
                   handleCancelEdit()
                 }
      -          // Don't close if clicking on the same message (already in edit mode)
                 return
               }
               
      -        // Close edit mode if clicking anywhere else (not a message box)
      +        // Check if clicking on the main user input at the bottom
      +        if (target.closest('textarea') || target.closest('input[type="text"]')) {
      +          handleCancelEdit()
      +          return
      +        }
      +        
      +        // Only close if NOT clicking on any component (i.e., clicking directly on panel background)
      +        // If the target has children or is a component, don't close
      +        if (target.children.length > 0 || target.tagName !== 'DIV') {
      +          return
      +        }
      +        
               handleCancelEdit()
             }
       
      +      const handleKeyDown = (event: KeyboardEvent) => {
      +        if (event.key === 'Escape') {
      +          handleCancelEdit()
      +        }
      +      }
      +
             // Use click event instead of mousedown to allow the target's click handler to fire first
             // Add listener with a slight delay to avoid immediate trigger when entering edit mode
             const timeoutId = setTimeout(() => {
               document.addEventListener('click', handleClickOutside, true) // Use capture phase
      +        document.addEventListener('keydown', handleKeyDown)
             }, 100)
       
             return () => {
               clearTimeout(timeoutId)
               document.removeEventListener('click', handleClickOutside, true)
      +        document.removeEventListener('keydown', handleKeyDown)
             }
           }, [isEditMode, message.id])
       
      @@ -668,19 +686,12 @@ const CopilotMessage: FC = memo(
                           return nodes
                         })()}
                         
      -                  {/* Gradient fade and ellipsis when truncated */}
      +                  {/* Gradient fade when truncated */}
                         {!isExpanded && needsExpansion && (
                           
      )}
      - {/* Show more indicator when truncated */} - {!isExpanded && needsExpansion && ( -
      - Click to expand... -
      - )} - {/* Abort button when hovering and response is generating (only on last user message) */} {isSendingMessage && isHoveringMessage && isLastUserMessage && (
      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 799bfe13471..d33dc657ea7 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,6 +9,7 @@ import { useRef, useState, } from 'react' +import { createPortal } from 'react-dom' import { ArrowUp, AtSign, @@ -130,8 +131,19 @@ const UserInput = forwardRef( const textareaRef = useRef(null) const overlayRef = useRef(null) const fileInputRef = useRef(null) + const containerRef = useRef(null) const [showMentionMenu, setShowMentionMenu] = useState(false) const mentionMenuRef = useRef(null) + const mentionPortalRef = useRef(null) + const [isNearTop, setIsNearTop] = useState(false) + const [mentionMenuMaxHeight, setMentionMenuMaxHeight] = useState(undefined) + const [mentionPortalStyle, setMentionPortalStyle] = useState<{ + top: number + left: number + width: number + maxHeight: number + showBelow: boolean + } | null>(null) const submenuRef = useRef(null) const menuListRef = useRef(null) const [mentionActiveIndex, setMentionActiveIndex] = useState(0) @@ -287,6 +299,44 @@ const UserInput = forwardRef( return () => textarea.removeEventListener('scroll', handleScroll) }, []) + // Detect if input is near the top of the screen (update dynamically) + useEffect(() => { + const checkPosition = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + // Consider "near top" if less than 300px from top of viewport + setIsNearTop(rect.top < 300) + } + } + + checkPosition() + + // Check position on scroll within the copilot panel + const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') + if (scrollContainer) { + scrollContainer.addEventListener('scroll', checkPosition, { passive: true }) + } + + window.addEventListener('scroll', checkPosition, true) + window.addEventListener('resize', checkPosition) + + return () => { + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', checkPosition) + } + window.removeEventListener('scroll', checkPosition, true) + window.removeEventListener('resize', checkPosition) + } + }, []) + + // Also check position when mention menu opens + useEffect(() => { + if (showMentionMenu && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setIsNearTop(rect.top < 300) + } + }, [showMentionMenu]) + // Close mention menu on outside click useEffect(() => { if (!showMentionMenu) return @@ -295,6 +345,7 @@ const UserInput = forwardRef( if ( mentionMenuRef.current && !mentionMenuRef.current.contains(target) && + (!mentionPortalRef.current || !mentionPortalRef.current.contains(target)) && (!submenuRef.current || !submenuRef.current.contains(target)) && textareaRef.current && !textareaRef.current.contains(target as Node) @@ -1956,8 +2007,139 @@ const UserInput = forwardRef( } }, [panelWidth, message, selectedContexts]) + // Update mention menu max height when visibility or position changes + useEffect(() => { + if (!showMentionMenu || !containerRef.current) { + setMentionMenuMaxHeight(undefined) + return + } + const rect = containerRef.current.getBoundingClientRect() + const margin = 16 + let available = isNearTop + ? window.innerHeight - rect.bottom - margin + : rect.top - margin + available = Math.max(available, 120) + setMentionMenuMaxHeight(available) + }, [showMentionMenu, isNearTop]) + + // Position the portal mention menu + useEffect(() => { + const updatePosition = () => { + if (!showMentionMenu || !containerRef.current || !textareaRef.current) { + setMentionPortalStyle(null) + return + } + const rect = containerRef.current.getBoundingClientRect() + const margin = 8 + + // Calculate cursor position using a temporary span + const textarea = textareaRef.current + const caretPos = getCaretPos() + + // Create a mirror div to calculate caret position + const div = document.createElement('div') + const style = window.getComputedStyle(textarea) + + // Copy relevant styles + div.style.position = 'absolute' + div.style.visibility = 'hidden' + div.style.whiteSpace = 'pre-wrap' + div.style.wordWrap = 'break-word' + div.style.font = style.font + div.style.padding = style.padding + div.style.border = style.border + div.style.width = style.width + div.style.lineHeight = style.lineHeight + + // Add text up to cursor position + const textBeforeCaret = message.substring(0, caretPos) + div.textContent = textBeforeCaret + + // Add a span at the end to measure position + const span = document.createElement('span') + span.textContent = '|' + div.appendChild(span) + + document.body.appendChild(div) + const spanRect = span.getBoundingClientRect() + const divRect = div.getBoundingClientRect() + document.body.removeChild(div) + + // Calculate the left offset relative to the textarea + const caretLeftOffset = spanRect.left - divRect.left + + // Calculate available space above and below + const spaceAbove = rect.top - margin + const spaceBelow = window.innerHeight - rect.bottom - margin + + // Cap max height to show ~8-10 items before scrolling (each item ~40px) + // This prevents the menu from extending too far in either direction + const maxMenuHeight = 360 + + // Show below if near top OR if more space below, otherwise show above + const showBelow = rect.top < 300 || spaceBelow > spaceAbove + + // Calculate max height based on available space, but never exceed maxMenuHeight + // Use the smaller of available space and our cap to ensure menu fits + const maxHeight = Math.min( + Math.max(showBelow ? spaceBelow : spaceAbove, 120), + maxMenuHeight + ) + + // Determine menu width based on submenu state + const menuWidth = openSubmenuFor === 'Blocks' + ? 320 + : openSubmenuFor === 'Templates' || openSubmenuFor === 'Logs' || aggregatedActive + ? 384 + : 224 + + // Calculate left position: use caret position but ensure menu doesn't go off-screen + const idealLeft = rect.left + caretLeftOffset + const maxLeft = window.innerWidth - menuWidth - margin + const finalLeft = Math.min(idealLeft, maxLeft) + + setMentionPortalStyle({ + top: showBelow ? rect.bottom + 4 : rect.top - 4, + left: Math.max(rect.left, finalLeft), // Don't go past left edge of container + width: menuWidth, + maxHeight: maxHeight, + showBelow, + }) + + // Update isNearTop state for reference + setIsNearTop(showBelow) + } + + let rafId: number | null = null + if (showMentionMenu) { + updatePosition() + window.addEventListener('resize', updatePosition) + + // Update position on scroll + const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') + if (scrollContainer) { + scrollContainer.addEventListener('scroll', updatePosition, { passive: true }) + } + + // Continuously update position (for smooth tracking) + const loop = () => { + updatePosition() + rafId = requestAnimationFrame(loop) + } + rafId = requestAnimationFrame(loop) + + return () => { + window.removeEventListener('resize', updatePosition) + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', updatePosition) + } + if (rafId) cancelAnimationFrame(rafId) + } + } + }, [showMentionMenu, openSubmenuFor, aggregatedActive, message]) + return ( -
      +
      (
                       {(() => {
      @@ -2149,24 +2332,36 @@ const UserInput = forwardRef(
                     placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
                     disabled={disabled}
                     rows={1}
      -              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent pl-[2px] pr-14 py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
      -              style={{ height: 'auto', wordBreak: 'break-word' }}
      +              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent pl-[2px] pr-14 py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
      +              style={{ height: 'auto', wordBreak: 'break-word', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
                   />
       
      -            {showMentionMenu && (
      -              <>
      +            {showMentionMenu && mentionPortalStyle && createPortal(
      +              
      {openSubmenuFor ? ( <> @@ -3023,7 +3218,8 @@ const UserInput = forwardRef( )}
      - +
      , + document.body )}
      @@ -3043,7 +3239,7 @@ const UserInput = forwardRef( {getModeText()} - +
      @@ -3145,7 +3341,7 @@ const UserInput = forwardRef( - +
      diff --git a/apps/sim/lib/copilot/inline-tool-call.tsx b/apps/sim/lib/copilot/inline-tool-call.tsx index 2f7a767c68d..13d1b50e54d 100644 --- a/apps/sim/lib/copilot/inline-tool-call.tsx +++ b/apps/sim/lib/copilot/inline-tool-call.tsx @@ -455,7 +455,7 @@ export function InlineToolCall({ >
      {renderDisplayIcon()}
      - {displayName} + {displayName}
      {showButtons ? ( From 5efe404aaa701a8567a3966354b29a2cda2ad2c5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 15:29:08 -0700 Subject: [PATCH 04/28] Fix models options --- apps/sim/app/api/copilot/user-models/route.ts | 17 ++-- .../components/user-input/user-input.tsx | 41 ++++----- .../panel/components/copilot/copilot.tsx | 86 +++++++++++++++++++ 3 files changed, 113 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index 3e5f782fb50..88aba636c11 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -67,15 +67,14 @@ export async function GET(request: NextRequest) { }) } - // If no settings record exists, create one with empty object (client will use defaults) - const [created] = await db - .insert(settings) - .values({ - id: userId, - userId, - copilotEnabledModels: {}, - }) - .returning() + // If no settings record exists, create one with defaults + await db.insert(settings).values({ + id: userId, + userId, + copilotEnabledModels: DEFAULT_ENABLED_MODELS, + }) + + logger.info('Created new settings record with default models', { userId }) return NextResponse.json({ enabledModels: DEFAULT_ENABLED_MODELS, 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 d33dc657ea7..ad25d087148 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 @@ -243,7 +243,6 @@ const UserInput = forwardRef( // Fetch enabled models when dropdown is opened for the first time const fetchEnabledModelsOnce = useCallback(async () => { - if (!isHosted) return if (enabledModels !== null) return // Already loaded try { @@ -1747,9 +1746,9 @@ const UserInput = forwardRef( { value: 'claude-4.1-opus', label: 'claude-4.1-opus' }, ] as const - // Filter models based on user preferences (only for hosted) + // Filter models based on user preferences const modelOptions = - isHosted && enabledModels !== null + enabledModels !== null ? allModelOptions.filter((model) => enabledModels.includes(model.value)) : allModelOptions @@ -3445,25 +3444,23 @@ const UserInput = forwardRef(
      - {/* More Models Button (only for hosted) */} - {isHosted && ( -
      - -
      - )} + {/* More Models Button */} +
      + +
      ) })()} 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 13d7ffc3477..c6cd227d58b 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 @@ -23,6 +23,20 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Copilot') +// Default enabled/disabled state for all models (must match API) +const DEFAULT_ENABLED_MODELS: Record = { + 'gpt-4o': false, + 'gpt-4.1': false, + 'gpt-5-fast': false, + 'gpt-5': true, + 'gpt-5-medium': true, + 'gpt-5-high': false, + o3: true, + 'claude-4-sonnet': true, + 'claude-4.5-sonnet': true, + 'claude-4.1-opus': true, +} + interface CopilotProps { panelWidth: number } @@ -40,6 +54,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref const [todosCollapsed, setTodosCollapsed] = useState(false) const lastWorkflowIdRef = useRef(null) const hasMountedRef = useRef(false) + const hasLoadedModelsRef = useRef(false) // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) @@ -71,8 +86,79 @@ export const Copilot = forwardRef(({ panelWidth }, ref chatsLoadedForWorkflow, setWorkflowId: setCopilotWorkflowId, loadChats, + enabledModels, + setEnabledModels, + selectedModel, + setSelectedModel, } = useCopilotStore() + // Load user's enabled models on mount + useEffect(() => { + const loadEnabledModels = async () => { + if (hasLoadedModelsRef.current) return + hasLoadedModelsRef.current = true + + try { + const res = await fetch('/api/copilot/user-models') + if (!res.ok) { + logger.warn('Failed to fetch user models, using defaults') + // Use defaults if fetch fails + const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter( + (key) => DEFAULT_ENABLED_MODELS[key] + ) + setEnabledModels(enabledArray) + return + } + + const data = await res.json() + const modelsMap = data.enabledModels || DEFAULT_ENABLED_MODELS + + // Convert map to array of enabled model IDs + const enabledArray = Object.entries(modelsMap) + .filter(([_, enabled]) => enabled) + .map(([modelId]) => modelId) + + setEnabledModels(enabledArray) + logger.info('Loaded user enabled models', { count: enabledArray.length }) + } catch (error) { + logger.error('Failed to load enabled models', { error }) + // Use defaults on error + const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter( + (key) => DEFAULT_ENABLED_MODELS[key] + ) + setEnabledModels(enabledArray) + } + } + + loadEnabledModels() + }, [setEnabledModels]) + + // Ensure selected model is in the enabled models list + useEffect(() => { + if (!enabledModels || enabledModels.length === 0) return + + // Check if current selected model is in the enabled list + if (selectedModel && !enabledModels.includes(selectedModel)) { + // Switch to the first enabled model (prefer claude-4.5-sonnet if available) + const preferredModel = 'claude-4.5-sonnet' + const fallbackModel = enabledModels[0] as typeof selectedModel + + if (enabledModels.includes(preferredModel)) { + setSelectedModel(preferredModel) + logger.info('Selected model not enabled, switching to preferred model', { + from: selectedModel, + to: preferredModel, + }) + } else if (fallbackModel) { + setSelectedModel(fallbackModel) + logger.info('Selected model not enabled, switching to first available', { + from: selectedModel, + to: fallbackModel, + }) + } + } + }, [enabledModels, selectedModel, setSelectedModel]) + // Force fresh initialization on mount (handles hot reload) useEffect(() => { if (activeWorkflowId && !hasMountedRef.current) { From 25b28ddd2b8a3d0bc98c60c8058d0c16a3839db5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 15:44:43 -0700 Subject: [PATCH 05/28] Add haiku 4.5 to copilot --- apps/sim/app/api/copilot/chat/route.ts | 1 + apps/sim/app/api/copilot/user-models/route.ts | 5 +++-- .../copilot/components/user-input/user-input.tsx | 2 ++ .../components/panel/components/copilot/copilot.tsx | 5 +++-- .../settings-modal/components/copilot/copilot.tsx | 5 ++++- apps/sim/lib/copilot/api.ts | 1 + apps/sim/providers/models.ts | 12 ++++++++++++ apps/sim/stores/copilot/types.ts | 1 + 8 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 558f61a94cb..cfa9025f2bc 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -48,6 +48,7 @@ const ChatMessageSchema = z.object({ 'gpt-4.1', 'o3', 'claude-4-sonnet', + 'claude-4.5-haiku', 'claude-4.5-sonnet', 'claude-4.1-opus', ]) diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index 88aba636c11..0b96115aa74 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -14,8 +14,9 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5': true, 'gpt-5-medium': true, 'gpt-5-high': false, - o3: true, - 'claude-4-sonnet': true, + 'o3': true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.1-opus': true, } 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 ad25d087148..d78f9b552b5 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 @@ -1742,6 +1742,7 @@ const UserInput = forwardRef( { value: 'gpt-4.1', label: 'gpt-4.1' }, { value: 'o3', label: 'o3' }, { value: 'claude-4-sonnet', label: 'claude-4-sonnet' }, + { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku' }, { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet' }, { value: 'claude-4.1-opus', label: 'claude-4.1-opus' }, ] as const @@ -3436,6 +3437,7 @@ const UserInput = forwardRef( .filter((option) => [ 'claude-4-sonnet', + 'claude-4.5-haiku', 'claude-4.5-sonnet', 'claude-4.1-opus', ].includes(option.value) 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 c6cd227d58b..cba3517e812 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 @@ -31,8 +31,9 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5': true, 'gpt-5-medium': true, 'gpt-5-high': false, - o3: true, - 'claude-4-sonnet': true, + 'o3': true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.1-opus': true, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index 7def67e0f9c..8dbac0365c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -44,6 +44,8 @@ const OPENAI_MODELS: ModelOption[] = [ ] const ANTHROPIC_MODELS: ModelOption[] = [ + // Zap models + { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' }, // Brain models { value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' }, { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' }, @@ -62,7 +64,8 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5-medium': true, 'gpt-5-high': false, o3: true, - 'claude-4-sonnet': true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.1-opus': true, } diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index ea3225a73de..0de6e62864d 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -66,6 +66,7 @@ export interface SendMessageRequest { | 'gpt-4.1' | 'o3' | 'claude-4-sonnet' + | 'claude-4.5-haiku' | 'claude-4.5-sonnet' | 'claude-4.1-opus' prefetch?: boolean diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index b25457f1954..b31d321fe99 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -367,6 +367,18 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'claude-haiku-4-5', + pricing: { + input: 1.0, + cachedInput: 0.5, + output: 5.0, + updatedAt: '2025-10-11', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + }, { id: 'claude-sonnet-4-5', pricing: { diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index cb875a756a4..b7b27f5625c 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -77,6 +77,7 @@ export interface CopilotState { | 'gpt-4.1' | 'o3' | 'claude-4-sonnet' + | 'claude-4.5-haiku' | 'claude-4.5-sonnet' | 'claude-4.1-opus' agentPrefetch: boolean From b90c239cfb301ef05c835dc3adf8c1dc61b1fbdf Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 15:55:02 -0700 Subject: [PATCH 06/28] Model ui for haiku --- .../copilot-message/copilot-message.tsx | 12 ++--- .../components/user-input/user-input.tsx | 44 ++++++++++--------- .../panel/components/copilot/copilot.tsx | 1 + .../components/copilot/copilot.tsx | 14 +++--- 4 files changed, 35 insertions(+), 36 deletions(-) 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 e797d69c65d..4a4cb251b40 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 @@ -8,7 +8,6 @@ import { Box, Check, Clipboard, - Edit, Info, LibraryBig, Loader2, @@ -40,10 +39,11 @@ const logger = createLogger('CopilotMessage') interface CopilotMessageProps { message: CopilotMessageType isStreaming?: boolean + panelWidth?: number } const CopilotMessage: FC = memo( - ({ message, isStreaming }) => { + ({ message, isStreaming, panelWidth = 308 }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' const [showCopySuccess, setShowCopySuccess] = useState(false) @@ -543,6 +543,7 @@ const CopilotMessage: FC = memo( placeholder='Edit your message...' mode={mode} onModeChange={setMode} + panelWidth={panelWidth} hideContextUsage={true} />
      @@ -707,13 +708,6 @@ const CopilotMessage: FC = memo(
      )} - - {/* Edit indicator on hover (only when not generating) */} - {!isSendingMessage && ( -
      - -
      - )}
      )} 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 d78f9b552b5..97f2efb0ddf 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 @@ -1759,7 +1759,7 @@ const UserInput = forwardRef( } const getModelIcon = () => { - // Only Brain and BrainCircuit models show purple when agentPrefetch is false + // Brain and BrainCircuit models show purple when agentPrefetch is false const isBrainModel = [ 'gpt-5', 'gpt-5-medium', @@ -1767,8 +1767,11 @@ const UserInput = forwardRef( 'claude-4.5-sonnet', ].includes(selectedModel) const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel) + const isHaikuModel = selectedModel === 'claude-4.5-haiku' + + // Haiku shows purple when selected, other zap models don't const colorClass = - (isBrainModel || isBrainCircuitModel) && !agentPrefetch + (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch ? 'text-[var(--brand-primary-hover-hex)]' : 'text-muted-foreground' @@ -1779,7 +1782,7 @@ const UserInput = forwardRef( if (isBrainModel) { return } - if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)) { + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast', 'claude-4.5-haiku'].includes(selectedModel)) { return } return @@ -3309,7 +3312,8 @@ const UserInput = forwardRef( const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes( selectedModel ) - const showPurple = (isBrainModel || isBrainCircuitModel) && !agentPrefetch + const isHaikuModel = selectedModel === 'claude-4.5-haiku' + const showPurple = (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch return ( ( ) { return } - if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(modelValue)) { + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast', 'claude-4.5-haiku'].includes(modelValue)) { return } return
      @@ -3405,41 +3409,41 @@ const UserInput = forwardRef( return ( <> - {/* OpenAI Models */} + {/* Anthropic Models */}
      - OpenAI + Anthropic
      {modelOptions .filter((option) => [ - 'gpt-5-fast', - 'gpt-5', - 'gpt-5-medium', - 'gpt-5-high', - 'gpt-4o', - 'gpt-4.1', - 'o3', + 'claude-4-sonnet', + 'claude-4.5-haiku', + 'claude-4.5-sonnet', + 'claude-4.1-opus', ].includes(option.value) ) .map(renderModelOption)}
      - {/* Anthropic Models */} + {/* OpenAI Models */}
      - Anthropic + OpenAI
      {modelOptions .filter((option) => [ - 'claude-4-sonnet', - 'claude-4.5-haiku', - 'claude-4.5-sonnet', - 'claude-4.1-opus', + 'gpt-5-fast', + 'gpt-5', + 'gpt-5-medium', + 'gpt-5-high', + 'gpt-4o', + 'gpt-4.1', + 'o3', ].includes(option.value) ) .map(renderModelOption)} 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 cba3517e812..a075aeff965 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 @@ -479,6 +479,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref isStreaming={ isSendingMessage && message.id === messages[messages.length - 1]?.id } + panelWidth={panelWidth} /> )) )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index 8dbac0365c2..f4c403e0e87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -44,7 +44,7 @@ const OPENAI_MODELS: ModelOption[] = [ ] const ANTHROPIC_MODELS: ModelOption[] = [ - // Zap models + // Zap model (Haiku) { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' }, // Brain models { value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' }, @@ -331,13 +331,13 @@ export function Copilot() {
      ) : (
      - {/* OpenAI Models */} + {/* Anthropic Models */}
      - OpenAI + Anthropic
      - {OPENAI_MODELS.map((model) => { + {ANTHROPIC_MODELS.map((model) => { const isEnabled = enabledModelsMap[model.value] ?? false return (
      - {/* Anthropic Models */} + {/* OpenAI Models */}
      - Anthropic + OpenAI
      - {ANTHROPIC_MODELS.map((model) => { + {OPENAI_MODELS.map((model) => { const isEnabled = enabledModelsMap[model.value] ?? false return (
      Date: Wed, 15 Oct 2025 16:21:23 -0700 Subject: [PATCH 07/28] Fix lint --- apps/sim/app/api/copilot/user-models/route.ts | 2 +- .../components/markdown-renderer.tsx | 7 +- .../components/thinking-block.tsx | 4 +- .../copilot-message/copilot-message.tsx | 55 +- .../components/user-input/user-input.tsx | 1587 +++++++++-------- .../panel/components/copilot/copilot.tsx | 2 +- .../tools/server/other/search-online.ts | 2 +- 7 files changed, 839 insertions(+), 820 deletions(-) diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index 0b96115aa74..a0e8c65e118 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -14,7 +14,7 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5': true, 'gpt-5-medium': true, 'gpt-5-high': false, - 'o3': true, + o3: true, 'claude-4-sonnet': false, 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx index 038b4315a6b..1852e71e25e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -141,7 +141,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend () => ({ // Paragraph p: ({ children }: React.HTMLAttributes) => ( -

      +

      {children}

      ), @@ -189,10 +189,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend children, ordered, }: React.LiHTMLAttributes & { ordered?: boolean }) => ( -
    2. +
    3. {children}
    4. ), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index bb6d6e8cda3..afde595f89f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -79,7 +79,7 @@ export function ThinkingBlock({ }) }} className={cn( - 'mb-1 inline-flex items-center gap-1 text-gray-400 text-[11px] transition-colors hover:text-gray-500', + 'mb-1 inline-flex items-center gap-1 text-[11px] text-gray-400 transition-colors hover:text-gray-500', 'font-normal italic' )} type='button' @@ -96,7 +96,7 @@ export function ThinkingBlock({ {isExpanded && (
      -
      +          
                   {content}
                   {isStreaming && (
                     
      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 4a4cb251b40..8970d642120 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
      @@ -10,8 +10,6 @@ import {
         Clipboard,
         Info,
         LibraryBig,
      -  Loader2,
      -  RotateCcw,
         Shapes,
         SquareChevronRight,
         ThumbsDown,
      @@ -26,13 +24,12 @@ import {
         SmoothStreamingText,
         StreamingIndicator,
         ThinkingBlock,
      -  WordWrap,
       } 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 { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
       import { usePreviewStore } from '@/stores/copilot/preview-store'
       import { useCopilotStore } from '@/stores/copilot/store'
       import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
      -import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
       
       const logger = createLogger('CopilotMessage')
       
      @@ -292,12 +289,12 @@ const CopilotMessage: FC = memo(
       
           const handleMessageClick = () => {
             if (isSendingMessage) return
      -      
      +
             // If message needs expansion and is not expanded, expand it
             if (needsExpansion && !isExpanded) {
               setIsExpanded(true)
             }
      -      
      +
             // Always enter edit mode on click
             handleEditMessage()
           }
      @@ -308,30 +305,30 @@ const CopilotMessage: FC = memo(
             contexts?: any[]
           ) => {
             if (!editedMessage.trim() || isSendingMessage) return
      -      
      +
             // Find the index of this message and truncate conversation
             const currentMessages = messages
             const editIndex = currentMessages.findIndex((m) => m.id === message.id)
      -      
      +
             if (editIndex !== -1) {
               // Exit edit mode immediately
               setIsEditMode(false)
      -        
      +
               // Truncate messages after the edited message (remove it and everything after)
               const truncatedMessages = currentMessages.slice(0, editIndex)
      -        
      +
               // Update store to show only messages before the edit point
               useCopilotStore.setState({ messages: truncatedMessages })
      -        
      +
               // If we have a current chat, update the DB to remove messages after this point
               if (currentChat?.id) {
                 try {
                   await fetch('/api/copilot/chat/update-messages', {
                     method: 'POST',
                     headers: { 'Content-Type': 'application/json' },
      -              body: JSON.stringify({ 
      -                chatId: currentChat.id, 
      -                messages: truncatedMessages.map(m => ({
      +              body: JSON.stringify({
      +                chatId: currentChat.id,
      +                messages: truncatedMessages.map((m) => ({
                         id: m.id,
                         role: m.role,
                         content: m.content,
      @@ -339,19 +336,19 @@ const CopilotMessage: FC = memo(
                         ...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
                         ...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
                         ...((m as any).contexts && { contexts: (m as any).contexts }),
      -                }))
      +                })),
                     }),
                   })
                 } catch (error) {
                   logger.error('Failed to update messages in DB after edit:', error)
                 }
               }
      -        
      +
               // Send the edited message with the SAME message ID
      -        await sendMessage(editedMessage, { 
      +        await sendMessage(editedMessage, {
                 fileAttachments: fileAttachments || message.fileAttachments,
                 contexts: contexts || (message as any).contexts,
      -          messageId: message.id  // Reuse the original message ID
      +          messageId: message.id, // Reuse the original message ID
               })
             }
           }
      @@ -389,12 +386,12 @@ const CopilotMessage: FC = memo(
       
             const handleClickOutside = (event: MouseEvent) => {
               const target = event.target as HTMLElement
      -        
      +
               // Don't close if clicking inside the edit container
      -        if (editContainerRef.current && editContainerRef.current.contains(target)) {
      +        if (editContainerRef.current?.contains(target)) {
                 return
               }
      -        
      +
               // Check if clicking on another user message box
               const clickedMessageBox = target.closest('[data-message-box]') as HTMLElement
               if (clickedMessageBox) {
      @@ -405,19 +402,19 @@ const CopilotMessage: FC = memo(
                 }
                 return
               }
      -        
      +
               // Check if clicking on the main user input at the bottom
               if (target.closest('textarea') || target.closest('input[type="text"]')) {
                 handleCancelEdit()
                 return
               }
      -        
      +
               // Only close if NOT clicking on any component (i.e., clicking directly on panel background)
               // If the target has children or is a component, don't close
               if (target.children.length > 0 || target.tagName !== 'DIV') {
                 return
               }
      -        
      +
               handleCancelEdit()
             }
       
      @@ -641,13 +638,13 @@ const CopilotMessage: FC = memo(
                       onMouseLeave={() => setIsHoveringMessage(false)}
                       className='group relative cursor-text rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 shadow-xs transition-all duration-200 hover:border-[#D0D0D0] dark:border-[#414141] dark:bg-[var(--surface-elevated)] dark:hover:border-[#525252]'
                     >
      -                
      {(() => { @@ -686,10 +683,10 @@ const CopilotMessage: FC = memo( if (tail) nodes.push(tail) return nodes })()} - + {/* Gradient fade when truncated */} {!isExpanded && needsExpansion && ( -
      +
      )}
      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 97f2efb0ddf..bd374328621 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,7 +9,6 @@ import { useRef, useState, } from 'react' -import { createPortal } from 'react-dom' import { ArrowUp, AtSign, @@ -21,7 +20,6 @@ import { BrainCircuit, Check, ChevronRight, - Edit, FileText, Image, Infinity as InfinityIcon, @@ -38,6 +36,7 @@ import { Zap, } from 'lucide-react' import { useParams } from 'next/navigation' +import { createPortal } from 'react-dom' import { Button, DropdownMenu, @@ -51,12 +50,11 @@ import { TooltipTrigger, } from '@/components/ui' import { useSession } from '@/lib/auth-client' -import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useCopilotStore } from '@/stores/copilot/store' import type { ChatContext } from '@/stores/copilot/types' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ContextUsagePill } from '../context-usage-pill/context-usage-pill' const logger = createLogger('CopilotUserInput') @@ -309,13 +307,13 @@ const UserInput = forwardRef( } checkPosition() - + // Check position on scroll within the copilot panel const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') if (scrollContainer) { scrollContainer.addEventListener('scroll', checkPosition, { passive: true }) } - + window.addEventListener('scroll', checkPosition, true) window.addEventListener('resize', checkPosition) @@ -327,7 +325,7 @@ const UserInput = forwardRef( window.removeEventListener('resize', checkPosition) } }, []) - + // Also check position when mention menu opens useEffect(() => { if (showMentionMenu && containerRef.current) { @@ -1768,7 +1766,7 @@ const UserInput = forwardRef( ].includes(selectedModel) const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel) const isHaikuModel = selectedModel === 'claude-4.5-haiku' - + // Haiku shows purple when selected, other zap models don't const colorClass = (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch @@ -2018,9 +2016,7 @@ const UserInput = forwardRef( } const rect = containerRef.current.getBoundingClientRect() const margin = 16 - let available = isNearTop - ? window.innerHeight - rect.bottom - margin - : rect.top - margin + let available = isNearTop ? window.innerHeight - rect.bottom - margin : rect.top - margin available = Math.max(available, 120) setMentionMenuMaxHeight(available) }, [showMentionMenu, isNearTop]) @@ -2034,15 +2030,15 @@ const UserInput = forwardRef( } const rect = containerRef.current.getBoundingClientRect() const margin = 8 - + // Calculate cursor position using a temporary span const textarea = textareaRef.current const caretPos = getCaretPos() - + // Create a mirror div to calculate caret position const div = document.createElement('div') const style = window.getComputedStyle(textarea) - + // Copy relevant styles div.style.position = 'absolute' div.style.visibility = 'hidden' @@ -2053,49 +2049,50 @@ const UserInput = forwardRef( div.style.border = style.border div.style.width = style.width div.style.lineHeight = style.lineHeight - + // Add text up to cursor position const textBeforeCaret = message.substring(0, caretPos) div.textContent = textBeforeCaret - + // Add a span at the end to measure position const span = document.createElement('span') span.textContent = '|' div.appendChild(span) - + document.body.appendChild(div) const spanRect = span.getBoundingClientRect() const divRect = div.getBoundingClientRect() document.body.removeChild(div) - + // Calculate the left offset relative to the textarea const caretLeftOffset = spanRect.left - divRect.left - + // Calculate available space above and below const spaceAbove = rect.top - margin const spaceBelow = window.innerHeight - rect.bottom - margin - + // Cap max height to show ~8-10 items before scrolling (each item ~40px) // This prevents the menu from extending too far in either direction const maxMenuHeight = 360 - + // Show below if near top OR if more space below, otherwise show above const showBelow = rect.top < 300 || spaceBelow > spaceAbove - + // Calculate max height based on available space, but never exceed maxMenuHeight // Use the smaller of available space and our cap to ensure menu fits const maxHeight = Math.min( Math.max(showBelow ? spaceBelow : spaceAbove, 120), maxMenuHeight ) - + // Determine menu width based on submenu state - const menuWidth = openSubmenuFor === 'Blocks' - ? 320 - : openSubmenuFor === 'Templates' || openSubmenuFor === 'Logs' || aggregatedActive - ? 384 - : 224 - + const menuWidth = + openSubmenuFor === 'Blocks' + ? 320 + : openSubmenuFor === 'Templates' || openSubmenuFor === 'Logs' || aggregatedActive + ? 384 + : 224 + // Calculate left position: use caret position but ensure menu doesn't go off-screen const idealLeft = rect.left + caretLeftOffset const maxLeft = window.innerWidth - menuWidth - margin @@ -2108,7 +2105,7 @@ const UserInput = forwardRef( maxHeight: maxHeight, showBelow, }) - + // Update isNearTop state for reference setIsNearTop(showBelow) } @@ -2117,20 +2114,20 @@ const UserInput = forwardRef( if (showMentionMenu) { updatePosition() window.addEventListener('resize', updatePosition) - + // Update position on scroll const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') if (scrollContainer) { scrollContainer.addEventListener('scroll', updatePosition, { passive: true }) } - + // Continuously update position (for smooth tracking) const loop = () => { updatePosition() rafId = requestAnimationFrame(loop) } rafId = requestAnimationFrame(loop) - + return () => { window.removeEventListener('resize', updatePosition) if (scrollContainer) { @@ -2280,7 +2277,7 @@ const UserInput = forwardRef( className='pointer-events-none absolute inset-0 z-[1] max-h-[120px] overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words [&::-webkit-scrollbar]:hidden' style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} > -
      +              
                       {(() => {
                         const elements: React.ReactNode[] = []
                         const remaining = message
      @@ -2335,769 +2332,487 @@ const UserInput = forwardRef(
                     placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
                     disabled={disabled}
                     rows={1}
      -              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent pl-[2px] pr-14 py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
      -              style={{ height: 'auto', wordBreak: 'break-word', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
      +              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent py-1 pr-14 pl-[2px] font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
      +              style={{
      +                height: 'auto',
      +                wordBreak: 'break-word',
      +                scrollbarWidth: 'none',
      +                msOverflowStyle: 'none',
      +              }}
                   />
       
      -            {showMentionMenu && mentionPortalStyle && createPortal(
      -              
      + {showMentionMenu && + mentionPortalStyle && + createPortal(
      - {openSubmenuFor ? ( - <> -
      - {openSubmenuFor === 'Chats' - ? 'Chats' - : openSubmenuFor === 'Workflows' - ? 'All workflows' - : openSubmenuFor === 'Knowledge' - ? 'Knowledge Bases' - : openSubmenuFor === 'Blocks' - ? 'Blocks' - : openSubmenuFor === 'Workflow Blocks' - ? 'Workflow Blocks' - : openSubmenuFor === 'Templates' - ? 'Templates' - : 'Logs'} -
      -
      - {isSubmenu('Chats') && ( - <> - {isLoadingPastChats ? ( -
      - Loading... -
      - ) : pastChats.length === 0 ? ( -
      - No past chats -
      - ) : ( - pastChats - .filter((c) => - (c.title || 'Untitled Chat') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((chat, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertPastChatMention(chat) - setSubmenuQueryStart(null) - }} - > -
      - -
      - - {chat.title || 'Untitled Chat'} - -
      - )) - )} - - )} - {isSubmenu('Workflows') && ( - <> - {isLoadingWorkflows ? ( -
      - Loading... -
      - ) : workflows.length === 0 ? ( -
      - No workflows -
      - ) : ( - workflows - .filter((w) => - (w.name || 'Untitled Workflow') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((wf, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertWorkflowMention(wf) - setSubmenuQueryStart(null) - }} - > -
      - - {wf.name || 'Untitled Workflow'} - -
      - )) - )} - - )} - {isSubmenu('Knowledge') && ( - <> - {isLoadingKnowledge ? ( -
      - Loading... -
      - ) : knowledgeBases.length === 0 ? ( -
      - No knowledge bases -
      - ) : ( - knowledgeBases - .filter((k) => - (k.name || 'Untitled') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((kb, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertKnowledgeMention(kb) - setSubmenuQueryStart(null) - }} - > - - {kb.name || 'Untitled'} -
      - )) - )} - - )} - {isSubmenu('Blocks') && ( - <> - {isLoadingBlocks ? ( -
      - Loading... -
      - ) : blocksList.length === 0 ? ( -
      - No blocks found -
      - ) : ( - blocksList - .filter((b) => - (b.name || b.id) - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((blk, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertBlockMention(blk) - setSubmenuQueryStart(null) - }} - > +
      + {openSubmenuFor ? ( + <> +
      + {openSubmenuFor === 'Chats' + ? 'Chats' + : openSubmenuFor === 'Workflows' + ? 'All workflows' + : openSubmenuFor === 'Knowledge' + ? 'Knowledge Bases' + : openSubmenuFor === 'Blocks' + ? 'Blocks' + : openSubmenuFor === 'Workflow Blocks' + ? 'Workflow Blocks' + : openSubmenuFor === 'Templates' + ? 'Templates' + : 'Logs'} +
      +
      + {isSubmenu('Chats') && ( + <> + {isLoadingPastChats ? ( +
      + Loading... +
      + ) : pastChats.length === 0 ? ( +
      + No past chats +
      + ) : ( + pastChats + .filter((c) => + (c.title || 'Untitled Chat') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((chat, idx) => (
      - {blk.iconComponent && ( - + key={chat.id} + data-idx={idx} + className={cn( + 'flex items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm hover:bg-muted/60', + submenuActiveIndex === idx && 'bg-muted' )} + role='menuitem' + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} + onClick={() => { + insertPastChatMention(chat) + setSubmenuQueryStart(null) + }} + > +
      + +
      + + {chat.title || 'Untitled Chat'} +
      - {blk.name || blk.id} -
      - )) - )} - - )} - {isSubmenu('Workflow Blocks') && ( - <> - {isLoadingWorkflowBlocks ? ( -
      - Loading... -
      - ) : workflowBlocks.length === 0 ? ( -
      - No blocks in this workflow -
      - ) : ( - workflowBlocks - .filter((b) => - (b.name || b.id) - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((blk, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertWorkflowBlockMention(blk) - setSubmenuQueryStart(null) - }} - > + )) + )} + + )} + {isSubmenu('Workflows') && ( + <> + {isLoadingWorkflows ? ( +
      + Loading... +
      + ) : workflows.length === 0 ? ( +
      + No workflows +
      + ) : ( + workflows + .filter((w) => + (w.name || 'Untitled Workflow') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((wf, idx) => (
      - {blk.iconComponent && ( - + key={wf.id} + data-idx={idx} + className={cn( + 'flex items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm hover:bg-muted/60', + submenuActiveIndex === idx && 'bg-muted' )} + role='menuitem' + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} + onClick={() => { + insertWorkflowMention(wf) + setSubmenuQueryStart(null) + }} + > +
      + + {wf.name || 'Untitled Workflow'} +
      - {blk.name || blk.id} -
      - )) - )} - - )} - {isSubmenu('Templates') && ( - <> - {isLoadingTemplates ? ( -
      - Loading... -
      - ) : templatesList.length === 0 ? ( -
      - No templates found -
      - ) : ( - templatesList - .filter((t) => - (t.name || 'Untitled Template') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((tpl, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertTemplateMention(tpl) - setSubmenuQueryStart(null) - }} - > -
      - ★ + )) + )} + + )} + {isSubmenu('Knowledge') && ( + <> + {isLoadingKnowledge ? ( +
      + Loading... +
      + ) : knowledgeBases.length === 0 ? ( +
      + No knowledge bases +
      + ) : ( + knowledgeBases + .filter((k) => + (k.name || 'Untitled') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((kb, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertKnowledgeMention(kb) + setSubmenuQueryStart(null) + }} + > + + {kb.name || 'Untitled'}
      - {tpl.name} - - {tpl.stars} - -
      - )) - )} - - )} - {isSubmenu('Logs') && ( - <> - {isLoadingLogs ? ( -
      - Loading... -
      - ) : logsList.length === 0 ? ( -
      - No executions found -
      - ) : ( - logsList - .filter((l) => - [l.workflowName, l.trigger || ''] - .join(' ') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((log, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertLogMention(log) - setSubmenuQueryStart(null) - }} - > - {log.level === 'error' ? ( - - ) : ( - - )} - {log.workflowName} - · - - {formatTimestamp(log.createdAt)} - - · - - {(log.trigger || 'manual').toLowerCase()} - -
      - )) - )} - - )} -
      - - ) : ( - <> - {(() => { - const q = ( - getActiveMentionQueryAtPosition(getCaretPos())?.query || '' - ).toLowerCase() - const filtered = mentionOptions.filter((label) => - label.toLowerCase().includes(q) - ) - if (q.length > 0 && filtered.length === 0) { - // Aggregated search view - const aggregated = [ - ...workflowBlocks - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ - type: 'Workflow Blocks' as const, - id: b.id, - value: b, - onClick: () => insertWorkflowBlockMention(b), - })), - ...workflows - .filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(q) - ) - .map((w) => ({ - type: 'Workflows' as const, - id: w.id, - value: w, - onClick: () => insertWorkflowMention(w), - })), - ...blocksList - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ - type: 'Blocks' as const, - id: b.id, - value: b, - onClick: () => insertBlockMention(b), - })), - ...knowledgeBases - .filter((k) => (k.name || 'Untitled').toLowerCase().includes(q)) - .map((k) => ({ - type: 'Knowledge' as const, - id: k.id, - value: k, - onClick: () => insertKnowledgeMention(k), - })), - ...templatesList - .filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(q) - ) - .map((t) => ({ - type: 'Templates' as const, - id: t.id, - value: t, - onClick: () => insertTemplateMention(t), - })), - ...pastChats - .filter((c) => (c.title || 'Untitled Chat').toLowerCase().includes(q)) - .map((c) => ({ - type: 'Chats' as const, - id: c.id, - value: c, - onClick: () => insertPastChatMention(c), - })), - ...logsList - .filter((l) => - (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q) - ) - .map((l) => ({ - type: 'Logs' as const, - id: l.id, - value: l, - onClick: () => insertLogMention(l), - })), - ] - return ( -
      - {aggregated.length === 0 ? ( + )) + )} + + )} + {isSubmenu('Blocks') && ( + <> + {isLoadingBlocks ? (
      - No matches + Loading... +
      + ) : blocksList.length === 0 ? ( +
      + No blocks found
      ) : ( - aggregated.map((item, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => item.onClick()} - > - {item.type === 'Chats' ? ( - <> -
      - -
      - - {(item.value as any).title || 'Untitled Chat'} - - - ) : item.type === 'Workflows' ? ( - <> -
      - - {(item.value as any).name || 'Untitled Workflow'} - - - ) : item.type === 'Knowledge' ? ( - <> - - - {(item.value as any).name || 'Untitled'} - - - ) : item.type === 'Blocks' ? ( - <> -
      - {(() => { - const Icon = (item.value as any).iconComponent - return Icon ? ( - - ) : null - })()} -
      - - {(item.value as any).name || (item.value as any).id} - - - ) : item.type === 'Workflow Blocks' ? ( - <> -
      - {(() => { - const Icon = (item.value as any).iconComponent - return Icon ? ( - - ) : null - })()} -
      - - {(item.value as any).name || (item.value as any).id} - - - ) : item.type === 'Logs' ? ( - <> - {(() => { - const v = item.value as any - return v.level === 'error' ? ( - - ) : ( - - ) - })()} - - {(item.value as any).workflowName} - - · - - {formatTimestamp((item.value as any).createdAt)} - - · - - {( - ((item.value as any).trigger as string) || 'manual' - ).toLowerCase()} - - - ) : ( - <> -
      - ★ -
      - - {(item.value as any).name || 'Untitled Template'} - - {typeof (item.value as any).stars === 'number' && ( - - {(item.value as any).stars} - + blocksList + .filter((b) => + (b.name || b.id) + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((blk, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertBlockMention(blk) + setSubmenuQueryStart(null) + }} + > +
      + {blk.iconComponent && ( + )} - - )} -
      - )) +
      + {blk.name || blk.id} +
      + )) )} -
      - ) - } - // Filtered top-level options view - return ( -
      - {filtered.map((label, idx) => ( -
      { - setInAggregated(false) - setMentionActiveIndex(idx) - }} - onClick={() => { - if (label === 'Chats') { - resetActiveMentionQuery() - setOpenSubmenuFor('Chats') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensurePastChatsLoaded() - } else if (label === 'Workflows') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflows') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowsLoaded() - } else if (label === 'Knowledge') { - resetActiveMentionQuery() - setOpenSubmenuFor('Knowledge') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureKnowledgeLoaded() - } else if (label === 'Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureBlocksLoaded() - } else if (label === 'Workflow Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflow Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowBlocksLoaded() - } else if (label === 'Docs') { - // No submenu; insert immediately - insertDocsMention() - } else if (label === 'Templates') { - resetActiveMentionQuery() - setOpenSubmenuFor('Templates') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureTemplatesLoaded() - } else if (label === 'Logs') { - resetActiveMentionQuery() - setOpenSubmenuFor('Logs') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureLogsLoaded() - } - }} - > -
      - {label === 'Chats' ? ( - - ) : label === 'Workflows' ? ( - - ) : label === 'Blocks' ? ( - - ) : label === 'Workflow Blocks' ? ( - - ) : label === 'Knowledge' ? ( - - ) : label === 'Docs' ? ( - - ) : label === 'Templates' ? ( - - ) : label === 'Logs' ? ( - - ) : ( -
      - )} - {label === 'Workflows' ? 'All workflows' : label} + + )} + {isSubmenu('Workflow Blocks') && ( + <> + {isLoadingWorkflowBlocks ? ( +
      + Loading...
      - {label !== 'Docs' && ( - - )} -
      - ))} - - {(() => { - const aq = ( - getActiveMentionQueryAtPosition(getCaretPos())?.query || '' - ).toLowerCase() - const filteredLen = mentionOptions.filter((label) => - label.toLowerCase().includes(aq) - ).length - const aggregated = [ - ...workflowBlocks - .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) - .map((b) => ({ type: 'Workflow Blocks' as const, value: b })), - ...workflows - .filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(aq) + ) : workflowBlocks.length === 0 ? ( +
      + No blocks in this workflow +
      + ) : ( + workflowBlocks + .filter((b) => + (b.name || b.id) + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) ) - .map((w) => ({ type: 'Workflows' as const, value: w })), - ...blocksList - .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) - .map((b) => ({ type: 'Blocks' as const, value: b })), - ...knowledgeBases - .filter((k) => (k.name || 'Untitled').toLowerCase().includes(aq)) - .map((k) => ({ type: 'Knowledge' as const, value: k })), - ...templatesList + .map((blk, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertWorkflowBlockMention(blk) + setSubmenuQueryStart(null) + }} + > +
      + {blk.iconComponent && ( + + )} +
      + {blk.name || blk.id} +
      + )) + )} + + )} + {isSubmenu('Templates') && ( + <> + {isLoadingTemplates ? ( +
      + Loading... +
      + ) : templatesList.length === 0 ? ( +
      + No templates found +
      + ) : ( + templatesList .filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(aq) - ) - .map((t) => ({ type: 'Templates' as const, value: t })), - ...pastChats - .filter((c) => - (c.title || 'Untitled Chat').toLowerCase().includes(aq) + (t.name || 'Untitled Template') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) ) - .map((c) => ({ type: 'Chats' as const, value: c })), - ...logsList + .map((tpl, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertTemplateMention(tpl) + setSubmenuQueryStart(null) + }} + > +
      + ★ +
      + {tpl.name} + + {tpl.stars} + +
      + )) + )} + + )} + {isSubmenu('Logs') && ( + <> + {isLoadingLogs ? ( +
      + Loading... +
      + ) : logsList.length === 0 ? ( +
      + No executions found +
      + ) : ( + logsList .filter((l) => - (l.workflowName || 'Untitled Workflow') + [l.workflowName, l.trigger || ''] + .join(' ') .toLowerCase() - .includes(aq) + .includes(getSubmenuQuery().toLowerCase()) ) - .map((l) => ({ type: 'Logs' as const, value: l })), - ] - if (!aq || aq.length === 0 || aggregated.length === 0) return null - return ( - <> -
      -
      - Matches -
      - {aggregated.map((item, idx) => ( + .map((log, idx) => (
      { - setInAggregated(true) - setSubmenuActiveIndex(idx) - }} + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} onClick={() => { - if (item.type === 'Chats') - insertPastChatMention(item.value as any) - else if (item.type === 'Workflows') - insertWorkflowMention(item.value as any) - else if (item.type === 'Knowledge') - insertKnowledgeMention(item.value as any) - else if (item.type === 'Blocks') - insertBlockMention(item.value as any) - else if ((item as any).type === 'Workflow Blocks') - insertWorkflowBlockMention(item.value as any) - else if (item.type === 'Templates') - insertTemplateMention(item.value as any) - else if (item.type === 'Logs') - insertLogMention(item.value as any) + insertLogMention(log) + setSubmenuQueryStart(null) }} + > + {log.level === 'error' ? ( + + ) : ( + + )} + {log.workflowName} + · + + {formatTimestamp(log.createdAt)} + + · + + {(log.trigger || 'manual').toLowerCase()} + +
      + )) + )} + + )} +
      + + ) : ( + <> + {(() => { + const q = ( + getActiveMentionQueryAtPosition(getCaretPos())?.query || '' + ).toLowerCase() + const filtered = mentionOptions.filter((label) => + label.toLowerCase().includes(q) + ) + if (q.length > 0 && filtered.length === 0) { + // Aggregated search view + const aggregated = [ + ...workflowBlocks + .filter((b) => (b.name || b.id).toLowerCase().includes(q)) + .map((b) => ({ + type: 'Workflow Blocks' as const, + id: b.id, + value: b, + onClick: () => insertWorkflowBlockMention(b), + })), + ...workflows + .filter((w) => + (w.name || 'Untitled Workflow').toLowerCase().includes(q) + ) + .map((w) => ({ + type: 'Workflows' as const, + id: w.id, + value: w, + onClick: () => insertWorkflowMention(w), + })), + ...blocksList + .filter((b) => (b.name || b.id).toLowerCase().includes(q)) + .map((b) => ({ + type: 'Blocks' as const, + id: b.id, + value: b, + onClick: () => insertBlockMention(b), + })), + ...knowledgeBases + .filter((k) => (k.name || 'Untitled').toLowerCase().includes(q)) + .map((k) => ({ + type: 'Knowledge' as const, + id: k.id, + value: k, + onClick: () => insertKnowledgeMention(k), + })), + ...templatesList + .filter((t) => + (t.name || 'Untitled Template').toLowerCase().includes(q) + ) + .map((t) => ({ + type: 'Templates' as const, + id: t.id, + value: t, + onClick: () => insertTemplateMention(t), + })), + ...pastChats + .filter((c) => + (c.title || 'Untitled Chat').toLowerCase().includes(q) + ) + .map((c) => ({ + type: 'Chats' as const, + id: c.id, + value: c, + onClick: () => insertPastChatMention(c), + })), + ...logsList + .filter((l) => + (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q) + ) + .map((l) => ({ + type: 'Logs' as const, + id: l.id, + value: l, + onClick: () => insertLogMention(l), + })), + ] + return ( +
      + {aggregated.length === 0 ? ( +
      + No matches +
      + ) : ( + aggregated.map((item, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => item.onClick()} > {item.type === 'Chats' ? ( <> @@ -3211,19 +2926,313 @@ const UserInput = forwardRef( )}
      - ))} - - ) - })()} -
      - ) - })()} - - )} -
      -
      , - document.body - )} + )) + )} +
      + ) + } + // Filtered top-level options view + return ( +
      + {filtered.map((label, idx) => ( +
      { + setInAggregated(false) + setMentionActiveIndex(idx) + }} + onClick={() => { + if (label === 'Chats') { + resetActiveMentionQuery() + setOpenSubmenuFor('Chats') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensurePastChatsLoaded() + } else if (label === 'Workflows') { + resetActiveMentionQuery() + setOpenSubmenuFor('Workflows') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureWorkflowsLoaded() + } else if (label === 'Knowledge') { + resetActiveMentionQuery() + setOpenSubmenuFor('Knowledge') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureKnowledgeLoaded() + } else if (label === 'Blocks') { + resetActiveMentionQuery() + setOpenSubmenuFor('Blocks') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureBlocksLoaded() + } else if (label === 'Workflow Blocks') { + resetActiveMentionQuery() + setOpenSubmenuFor('Workflow Blocks') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureWorkflowBlocksLoaded() + } else if (label === 'Docs') { + // No submenu; insert immediately + insertDocsMention() + } else if (label === 'Templates') { + resetActiveMentionQuery() + setOpenSubmenuFor('Templates') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureTemplatesLoaded() + } else if (label === 'Logs') { + resetActiveMentionQuery() + setOpenSubmenuFor('Logs') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureLogsLoaded() + } + }} + > +
      + {label === 'Chats' ? ( + + ) : label === 'Workflows' ? ( + + ) : label === 'Blocks' ? ( + + ) : label === 'Workflow Blocks' ? ( + + ) : label === 'Knowledge' ? ( + + ) : label === 'Docs' ? ( + + ) : label === 'Templates' ? ( + + ) : label === 'Logs' ? ( + + ) : ( +
      + )} + {label === 'Workflows' ? 'All workflows' : label} +
      + {label !== 'Docs' && ( + + )} +
      + ))} + + {(() => { + const aq = ( + getActiveMentionQueryAtPosition(getCaretPos())?.query || '' + ).toLowerCase() + const filteredLen = mentionOptions.filter((label) => + label.toLowerCase().includes(aq) + ).length + const aggregated = [ + ...workflowBlocks + .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) + .map((b) => ({ type: 'Workflow Blocks' as const, value: b })), + ...workflows + .filter((w) => + (w.name || 'Untitled Workflow').toLowerCase().includes(aq) + ) + .map((w) => ({ type: 'Workflows' as const, value: w })), + ...blocksList + .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) + .map((b) => ({ type: 'Blocks' as const, value: b })), + ...knowledgeBases + .filter((k) => + (k.name || 'Untitled').toLowerCase().includes(aq) + ) + .map((k) => ({ type: 'Knowledge' as const, value: k })), + ...templatesList + .filter((t) => + (t.name || 'Untitled Template').toLowerCase().includes(aq) + ) + .map((t) => ({ type: 'Templates' as const, value: t })), + ...pastChats + .filter((c) => + (c.title || 'Untitled Chat').toLowerCase().includes(aq) + ) + .map((c) => ({ type: 'Chats' as const, value: c })), + ...logsList + .filter((l) => + (l.workflowName || 'Untitled Workflow') + .toLowerCase() + .includes(aq) + ) + .map((l) => ({ type: 'Logs' as const, value: l })), + ] + if (!aq || aq.length === 0 || aggregated.length === 0) return null + return ( + <> +
      +
      + Matches +
      + {aggregated.map((item, idx) => ( +
      { + setInAggregated(true) + setSubmenuActiveIndex(idx) + }} + onClick={() => { + if (item.type === 'Chats') + insertPastChatMention(item.value as any) + else if (item.type === 'Workflows') + insertWorkflowMention(item.value as any) + else if (item.type === 'Knowledge') + insertKnowledgeMention(item.value as any) + else if (item.type === 'Blocks') + insertBlockMention(item.value as any) + else if ((item as any).type === 'Workflow Blocks') + insertWorkflowBlockMention(item.value as any) + else if (item.type === 'Templates') + insertTemplateMention(item.value as any) + else if (item.type === 'Logs') + insertLogMention(item.value as any) + }} + > + {item.type === 'Chats' ? ( + <> +
      + +
      + + {(item.value as any).title || 'Untitled Chat'} + + + ) : item.type === 'Workflows' ? ( + <> +
      + + {(item.value as any).name || 'Untitled Workflow'} + + + ) : item.type === 'Knowledge' ? ( + <> + + + {(item.value as any).name || 'Untitled'} + + + ) : item.type === 'Blocks' ? ( + <> +
      + {(() => { + const Icon = (item.value as any).iconComponent + return Icon ? ( + + ) : null + })()} +
      + + {(item.value as any).name || (item.value as any).id} + + + ) : item.type === 'Workflow Blocks' ? ( + <> +
      + {(() => { + const Icon = (item.value as any).iconComponent + return Icon ? ( + + ) : null + })()} +
      + + {(item.value as any).name || (item.value as any).id} + + + ) : item.type === 'Logs' ? ( + <> + {(() => { + const v = item.value as any + return v.level === 'error' ? ( + + ) : ( + + ) + })()} + + {(item.value as any).workflowName} + + · + + {formatTimestamp((item.value as any).createdAt)} + + · + + {( + ((item.value as any).trigger as string) || 'manual' + ).toLowerCase()} + + + ) : ( + <> +
      + ★ +
      + + {(item.value as any).name || 'Untitled Template'} + + {typeof (item.value as any).stars === 'number' && ( + + {(item.value as any).stars} + + )} + + )} +
      + ))} + + ) + })()} +
      + ) + })()} + + )} +
      +
      , + document.body + )}
      {/* Bottom Row: Mode Selector + Attach Button + Send Button */} @@ -3242,7 +3251,11 @@ const UserInput = forwardRef( {getModeText()} - +
      @@ -3313,7 +3326,8 @@ const UserInput = forwardRef( selectedModel ) const isHaikuModel = selectedModel === 'claude-4.5-haiku' - const showPurple = (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch + const showPurple = + (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch return ( ( - +
      @@ -3374,7 +3392,14 @@ const UserInput = forwardRef( ) { return } - if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast', 'claude-4.5-haiku'].includes(modelValue)) { + if ( + [ + 'gpt-4o', + 'gpt-4.1', + 'gpt-5-fast', + 'claude-4.5-haiku', + ].includes(modelValue) + ) { return } return
      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 a075aeff965..eb731f38ebb 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 @@ -31,7 +31,7 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5': true, 'gpt-5-medium': true, 'gpt-5-high': false, - 'o3': true, + o3: true, 'claude-4-sonnet': false, 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index ea28794fa3d..c49b1e928fd 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -21,7 +21,7 @@ export const searchOnlineServerTool: BaseServerTool = { // Check which API keys are available const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0) const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) - + logger.info('Performing online search', { queryLength: query.length, num, From 43d26f0db450bf365eed55ea26ee8b20b2c7b27a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 17:20:51 -0700 Subject: [PATCH 08/28] Revert --- .../copilot-message/copilot-message.tsx | 223 +++++++++++++++++- 1 file changed, 212 insertions(+), 11 deletions(-) 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 8970d642120..fdf501274b9 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 @@ -10,6 +10,7 @@ import { Clipboard, Info, LibraryBig, + RotateCcw, Shapes, SquareChevronRight, ThumbsDown, @@ -17,6 +18,17 @@ import { Workflow, X, } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Checkbox, +} from '@/components/ui' import { InlineToolCall } from '@/lib/copilot/inline-tool-call' import { createLogger } from '@/lib/logs/console/logger' import { @@ -46,15 +58,22 @@ const CopilotMessage: FC = memo( const [showCopySuccess, setShowCopySuccess] = useState(false) const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false) const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false) - const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) - const [showAllContexts, setShowAllContexts] = useState(false) - const [isEditMode, setIsEditMode] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) - const [editedContent, setEditedContent] = useState(message.content) - const [isHoveringMessage, setIsHoveringMessage] = useState(false) - const editContainerRef = useRef(null) - const messageContentRef = useRef(null) - const [needsExpansion, setNeedsExpansion] = useState(false) + const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) + const [showAllContexts, setShowAllContexts] = useState(false) + const [isEditMode, setIsEditMode] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [editedContent, setEditedContent] = useState(message.content) + const [isHoveringMessage, setIsHoveringMessage] = useState(false) + const editContainerRef = useRef(null) + const messageContentRef = useRef(null) + const [needsExpansion, setNeedsExpansion] = useState(false) + const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) + const [dontAskAgain, setDontAskAgain] = useState(false) + const pendingEditRef = useRef<{ + message: string + fileAttachments?: any[] + contexts?: any[] + } | null>(null) // Get checkpoint functionality from copilot store const { @@ -79,7 +98,8 @@ const CopilotMessage: FC = memo( // Get checkpoints for this message if it's a user message const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : [] - const hasCheckpoints = messageCheckpoints.length > 0 + // Only consider it as having checkpoints if there's at least one valid checkpoint with an 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(() => { @@ -306,6 +326,24 @@ const CopilotMessage: FC = memo( ) => { if (!editedMessage.trim() || isSendingMessage) return + // Check if this message has checkpoints and user hasn't opted out + if (hasCheckpoints && !dontAskAgain) { + // Store the pending edit + pendingEditRef.current = { message: editedMessage, fileAttachments, contexts } + // Show confirmation modal + setShowCheckpointDiscardModal(true) + return + } + + // Proceed with the edit + await performEdit(editedMessage, fileAttachments, contexts) + } + + const performEdit = async ( + editedMessage: string, + fileAttachments?: any[], + contexts?: any[] + ) => { // Find the index of this message and truncate conversation const currentMessages = messages const editIndex = currentMessages.findIndex((m) => m.id === message.id) @@ -530,7 +568,7 @@ const CopilotMessage: FC = memo( return (
      {isEditMode ? ( -
      +
      = memo( panelWidth={panelWidth} hideContextUsage={true} /> + {/* Revert button in edit mode (only if has checkpoints) */} + {hasCheckpoints && ( + + )}
      ) : (
      @@ -705,9 +756,159 @@ const CopilotMessage: FC = memo(
      )} + + {/* Revert button on hover (only when has checkpoints and not generating) */} + {!isSendingMessage && hasCheckpoints && ( +
      + +
      + )}
      )} + + {/* Restore Checkpoint Confirmation Modal */} + + + + Revert to checkpoint? + + This will revert your workflow to the state saved at this checkpoint.{' '} + This action cannot be undone. + + + + + Cancel + + + Revert + + + + + + {/* Restore Checkpoint Confirmation Modal */} + + + + Revert to checkpoint? + + This will revert your workflow to the state saved at this checkpoint.{' '} + This action cannot be undone. + + + + + Cancel + + + Revert + + + + + + {/* Checkpoint Discard Confirmation Modal */} + { + setShowCheckpointDiscardModal(open) + if (!open) { + pendingEditRef.current = null + } + }} + > + + + Submit from a previous message? + + Submitting from a previous message will revert file changes to before this message + and clear the messages after this one. + + + +
      + setDontAskAgain(!!checked)} + /> + +
      + + + + Cancel (esc) + + + + +
      +
      ) } From 8cd1949d06dab61ab03c1f87e92abcb88665e9da Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 18:16:03 -0700 Subject: [PATCH 09/28] Only allow one revert to message --- .../api/copilot/checkpoints/revert/route.ts | 12 ++ .../copilot-message/copilot-message.tsx | 131 +++++++++++------- .../panel/components/copilot/copilot.tsx | 42 ++++-- 3 files changed, 127 insertions(+), 58 deletions(-) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 16162a96a22..2778e554d0b 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -118,6 +118,18 @@ export async function POST(request: NextRequest) { `[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}` ) + // Delete the checkpoint after successfully reverting to it + try { + await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId)) + logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId }) + } catch (deleteError) { + logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, { + checkpointId, + error: deleteError, + }) + // Don't fail the request if deletion fails - the revert was successful + } + return NextResponse.json({ success: true, workflowId: checkpoint.workflowId, 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 fdf501274b9..719f9fe27bb 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 @@ -8,6 +8,7 @@ import { Box, Check, Clipboard, + CornerDownLeft, Info, LibraryBig, RotateCcw, @@ -49,10 +50,13 @@ interface CopilotMessageProps { message: CopilotMessageType isStreaming?: boolean panelWidth?: number + isDimmed?: boolean + checkpointCount?: number + onEditModeChange?: (isEditing: boolean) => void } const CopilotMessage: FC = memo( - ({ message, isStreaming, panelWidth = 308 }) => { + ({ message, isStreaming, panelWidth = 308, isDimmed = false, checkpointCount = 0, onEditModeChange }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' const [showCopySuccess, setShowCopySuccess] = useState(false) @@ -68,7 +72,6 @@ const CopilotMessage: FC = memo( const messageContentRef = useRef(null) const [needsExpansion, setNeedsExpansion] = useState(false) const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) - const [dontAskAgain, setDontAskAgain] = useState(false) const pendingEditRef = useRef<{ message: string fileAttachments?: any[] @@ -284,7 +287,20 @@ const CopilotMessage: FC = memo( const latestCheckpoint = messageCheckpoints[0] try { await revertToCheckpoint(latestCheckpoint.id) + + // Remove the used checkpoint from the store + const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() + const updatedCheckpoints = { + ...currentCheckpoints, + [message.id]: messageCheckpoints.slice(1), // Remove the first (used) checkpoint + } + useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) + setShowRestoreConfirmation(false) + logger.info('Checkpoint reverted and removed from message', { + messageId: message.id, + checkpointId: latestCheckpoint.id + }) } catch (error) { logger.error('Failed to revert to checkpoint:', error) setShowRestoreConfirmation(false) @@ -300,11 +316,13 @@ const CopilotMessage: FC = memo( setIsEditMode(true) setIsExpanded(false) setEditedContent(message.content) + onEditModeChange?.(true) } const handleCancelEdit = () => { setIsEditMode(false) setEditedContent(message.content) + onEditModeChange?.(false) } const handleMessageClick = () => { @@ -326,8 +344,8 @@ const CopilotMessage: FC = memo( ) => { if (!editedMessage.trim() || isSendingMessage) return - // Check if this message has checkpoints and user hasn't opted out - if (hasCheckpoints && !dontAskAgain) { + // Check if this message has checkpoints + if (hasCheckpoints) { // Store the pending edit pendingEditRef.current = { message: editedMessage, fileAttachments, contexts } // Show confirmation modal @@ -351,6 +369,7 @@ const CopilotMessage: FC = memo( if (editIndex !== -1) { // Exit edit mode immediately setIsEditMode(false) + onEditModeChange?.(false) // Truncate messages after the edited message (remove it and everything after) const truncatedMessages = currentMessages.slice(0, editIndex) @@ -566,7 +585,7 @@ const CopilotMessage: FC = memo( if (isUser) { return ( -
      +
      {isEditMode ? (
      = memo( panelWidth={panelWidth} hideContextUsage={true} /> - {/* Revert button in edit mode (only if has checkpoints) */} - {hasCheckpoints && ( - - )}
      ) : (
      @@ -806,8 +812,10 @@ const CopilotMessage: FC = memo( Revert to checkpoint? - This will revert your workflow to the state saved at this checkpoint.{' '} - This action cannot be undone. + This will revert your workflow to the state saved at this checkpoint. + + This action cannot be undone. + @@ -834,33 +842,31 @@ const CopilotMessage: FC = memo( } }} > - + { + if (e.key === 'Enter') { + e.preventDefault() + // Trigger continue and revert + const revertButton = e.currentTarget.querySelector('[data-action="revert"]') as HTMLButtonElement + revertButton?.click() + } + }} + > - Submit from a previous message? - - Submitting from a previous message will revert file changes to before this message - and clear the messages after this one. - + Continue from a previous message? -
      - setDontAskAgain(!!checked)} - /> - -
      - - +
      @@ -915,7 +935,7 @@ const CopilotMessage: FC = memo( if (isAssistant) { return ( -
      +
      {/* Content blocks in chronological order */} {memoizedContentBlocks} @@ -1012,6 +1032,21 @@ const CopilotMessage: FC = memo( 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 + } + // For streaming messages, check if content actually changed if (nextProps.isStreaming) { const prevBlocks = prevMessage.contentBlocks || [] 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 eb731f38ebb..c8a3b28e94c 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 @@ -56,6 +56,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref const lastWorkflowIdRef = useRef(null) const hasMountedRef = useRef(false) const hasLoadedModelsRef = useRef(false) + const [editingMessageId, setEditingMessageId] = useState(null) // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) @@ -91,6 +92,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref setEnabledModels, selectedModel, setSelectedModel, + messageCheckpoints, } = useCopilotStore() // Load user's enabled models on mount @@ -444,6 +446,11 @@ export const Copilot = forwardRef(({ panelWidth }, ref [isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos] ) + const handleEditModeChange = useCallback((messageId: string, isEditing: boolean) => { + setEditingMessageId(isEditing ? messageId : null) + logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing }) + }, []) + return ( <>
      @@ -472,16 +479,31 @@ export const Copilot = forwardRef(({ panelWidth }, ref />
      ) : ( - messages.map((message) => ( - - )) + messages.map((message, index) => { + // Determine if this message should be dimmed (comes after the message being edited) + let isDimmed = false + if (editingMessageId) { + const editingIndex = messages.findIndex((m) => m.id === editingMessageId) + isDimmed = editingIndex !== -1 && index > editingIndex + } + + // Get checkpoint count for this message to force re-render when it changes + const checkpointCount = messageCheckpoints[message.id]?.length || 0 + + return ( + handleEditModeChange(message.id, isEditing)} + /> + ) + }) )}
      From a7f3d87684098d7692d7722c4432fabcfa39a1e2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 18:19:02 -0700 Subject: [PATCH 10/28] Clear diff on revert --- apps/sim/stores/copilot/store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 76d1f770533..373e2e86ad8 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1972,6 +1972,11 @@ export const useCopilotStore = create()( const result = await response.json() const reverted = result?.checkpoint?.workflowState || null if (reverted) { + // Clear any active diff preview + try { + useWorkflowDiffStore.getState().clearDiff() + } catch {} + // Apply to main workflow store useWorkflowStore.setState({ blocks: reverted.blocks || {}, From c76e1607a330f51439d55738917f500c2a44150c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 18:33:19 -0700 Subject: [PATCH 11/28] Fix welcome screen flash --- .../copilot/components/copilot-message/copilot-message.tsx | 7 +++++-- .../components/panel/components/copilot/copilot.tsx | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) 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 719f9fe27bb..bd532c099d8 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 @@ -367,9 +367,9 @@ const CopilotMessage: FC = memo( const editIndex = currentMessages.findIndex((m) => m.id === message.id) if (editIndex !== -1) { - // Exit edit mode immediately + // Exit edit mode visually setIsEditMode(false) - onEditModeChange?.(false) + // Keep editing flag true to prevent welcome screen flash // Truncate messages after the edited message (remove it and everything after) const truncatedMessages = currentMessages.slice(0, editIndex) @@ -407,6 +407,9 @@ const CopilotMessage: FC = memo( contexts: contexts || (message as any).contexts, messageId: message.id, // Reuse the original message ID }) + + // Clear editing state after message is sent + onEditModeChange?.(false) } } 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 c8a3b28e94c..101838d6c9a 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 @@ -57,6 +57,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref const hasMountedRef = useRef(false) const hasLoadedModelsRef = useRef(false) const [editingMessageId, setEditingMessageId] = useState(null) + const [isEditingMessage, setIsEditingMessage] = useState(false) // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) @@ -448,6 +449,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref const handleEditModeChange = useCallback((messageId: string, isEditing: boolean) => { setEditingMessageId(isEditing ? messageId : null) + setIsEditingMessage(isEditing) logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing }) }, []) @@ -471,7 +473,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref
      - {messages.length === 0 && !isSendingMessage ? ( + {messages.length === 0 && !isSendingMessage && !isEditingMessage ? (
      Date: Wed, 15 Oct 2025 18:43:35 -0700 Subject: [PATCH 12/28] Add focus onto the user input box when clicked --- .../copilot-message/copilot-message.tsx | 8 ++++- .../components/user-input/user-input.tsx | 29 ++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) 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 bd532c099d8..7a019d469e8 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 @@ -39,7 +39,7 @@ import { ThinkingBlock, } 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 { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' +import { UserInput, type UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types' @@ -70,6 +70,7 @@ const CopilotMessage: FC = memo( const [isHoveringMessage, setIsHoveringMessage] = useState(false) const editContainerRef = useRef(null) const messageContentRef = useRef(null) + const userInputRef = useRef(null) const [needsExpansion, setNeedsExpansion] = useState(false) const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) const pendingEditRef = useRef<{ @@ -317,6 +318,10 @@ const CopilotMessage: FC = memo( setIsExpanded(false) setEditedContent(message.content) onEditModeChange?.(true) + // Focus the input and position cursor at the end after render + setTimeout(() => { + userInputRef.current?.focus() + }, 0) } const handleCancelEdit = () => { @@ -592,6 +597,7 @@ const CopilotMessage: FC = memo( {isEditMode ? (
      ( ref, () => ({ focus: () => { - textareaRef.current?.focus() + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + // Position cursor at the end of the text + const length = textarea.value.length + textarea.setSelectionRange(length, length) + // Scroll to the end + textarea.scrollTop = textarea.scrollHeight + } }, }), [] @@ -239,6 +247,12 @@ const UserInput = forwardRef( } }, [workflowId]) + // Reset past chats when workflow changes to ensure we only load chats from the current workflow + useEffect(() => { + setPastChats([]) + setIsLoadingPastChats(false) + }, [workflowId]) + // Fetch enabled models when dropdown is opened for the first time const fetchEnabledModelsOnce = useCallback(async () => { if (enabledModels !== null) return // Already loaded @@ -364,18 +378,13 @@ const UserInput = forwardRef( const data = await resp.json() const items = Array.isArray(data?.chats) ? data.chats : [] - if (workflows.length === 0) { - await ensureWorkflowsLoaded() - } - - const workspaceWorkflowIds = new Set(workflows.map((w) => w.id)) - - const workspaceChats = items.filter( - (c: any) => !c.workflowId || workspaceWorkflowIds.has(c.workflowId) + // Filter chats to only include those from the current workflow + const currentWorkflowChats = items.filter( + (c: any) => c.workflowId === workflowId ) setPastChats( - workspaceChats.map((c: any) => ({ + currentWorkflowChats.map((c: any) => ({ id: c.id, title: c.title ?? null, workflowId: c.workflowId ?? null, From e6dcb55acc5d28d396b5269b969eb7564602d2cb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 18:46:40 -0700 Subject: [PATCH 13/28] Fix grayout of new stream on old edit message --- .../copilot-message/copilot-message.tsx | 6 ++-- .../panel/components/copilot/copilot.tsx | 28 ++++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) 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 7a019d469e8..1e2bb485137 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 @@ -374,7 +374,8 @@ const CopilotMessage: FC = memo( if (editIndex !== -1) { // Exit edit mode visually setIsEditMode(false) - // Keep editing flag true to prevent welcome screen flash + // Clear editing state in parent immediately to prevent dimming of new messages + onEditModeChange?.(false) // Truncate messages after the edited message (remove it and everything after) const truncatedMessages = currentMessages.slice(0, editIndex) @@ -412,9 +413,6 @@ const CopilotMessage: FC = memo( contexts: contexts || (message as any).contexts, messageId: message.id, // Reuse the original message ID }) - - // Clear editing state after message is sent - onEditModeChange?.(false) } } 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 101838d6c9a..95bec4c2152 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 @@ -541,19 +541,21 @@ export const Copilot = forwardRef(({ panelWidth }, ref {/* Input area with integrated mode selector */} {!showCheckpoints && ( - +
      + +
      )} )} From 52a1bc76ad3e6d77e6a4ed26c363429a2e8a0032 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 15 Oct 2025 18:46:55 -0700 Subject: [PATCH 14/28] Lint --- .../copilot-message/copilot-message.tsx | 102 ++++++++++-------- .../components/user-input/user-input.tsx | 4 +- .../panel/components/copilot/copilot.tsx | 4 +- 3 files changed, 62 insertions(+), 48 deletions(-) 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 1e2bb485137..c66c5fe7ffd 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 @@ -28,7 +28,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - Checkbox, } from '@/components/ui' import { InlineToolCall } from '@/lib/copilot/inline-tool-call' import { createLogger } from '@/lib/logs/console/logger' @@ -39,7 +38,10 @@ import { ThinkingBlock, } 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 { UserInput, type UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' +import { + UserInput, + type UserInputRef, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types' @@ -56,28 +58,35 @@ interface CopilotMessageProps { } const CopilotMessage: FC = memo( - ({ message, isStreaming, panelWidth = 308, isDimmed = false, checkpointCount = 0, onEditModeChange }) => { + ({ + message, + isStreaming, + panelWidth = 308, + isDimmed = false, + checkpointCount = 0, + onEditModeChange, + }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' const [showCopySuccess, setShowCopySuccess] = useState(false) const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false) const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false) - const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) - const [showAllContexts, setShowAllContexts] = useState(false) - const [isEditMode, setIsEditMode] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) - const [editedContent, setEditedContent] = useState(message.content) - const [isHoveringMessage, setIsHoveringMessage] = useState(false) - const editContainerRef = useRef(null) - const messageContentRef = useRef(null) - const userInputRef = useRef(null) - const [needsExpansion, setNeedsExpansion] = useState(false) - const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) - const pendingEditRef = useRef<{ - message: string - fileAttachments?: any[] - contexts?: any[] - } | null>(null) + const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) + const [showAllContexts, setShowAllContexts] = useState(false) + const [isEditMode, setIsEditMode] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [editedContent, setEditedContent] = useState(message.content) + const [isHoveringMessage, setIsHoveringMessage] = useState(false) + const editContainerRef = useRef(null) + const messageContentRef = useRef(null) + const userInputRef = useRef(null) + const [needsExpansion, setNeedsExpansion] = useState(false) + const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) + const pendingEditRef = useRef<{ + message: string + fileAttachments?: any[] + contexts?: any[] + } | null>(null) // Get checkpoint functionality from copilot store const { @@ -103,7 +112,7 @@ const CopilotMessage: FC = memo( // Get checkpoints for this message if it's a user message const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : [] // Only consider it as having checkpoints if there's at least one valid checkpoint with an id - const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some(cp => cp?.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(() => { @@ -288,7 +297,7 @@ const CopilotMessage: FC = memo( const latestCheckpoint = messageCheckpoints[0] try { await revertToCheckpoint(latestCheckpoint.id) - + // Remove the used checkpoint from the store const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() const updatedCheckpoints = { @@ -296,11 +305,11 @@ const CopilotMessage: FC = memo( [message.id]: messageCheckpoints.slice(1), // Remove the first (used) checkpoint } useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) - + setShowRestoreConfirmation(false) - logger.info('Checkpoint reverted and removed from message', { - messageId: message.id, - checkpointId: latestCheckpoint.id + logger.info('Checkpoint reverted and removed from message', { + messageId: message.id, + checkpointId: latestCheckpoint.id, }) } catch (error) { logger.error('Failed to revert to checkpoint:', error) @@ -591,7 +600,9 @@ const CopilotMessage: FC = memo( if (isUser) { return ( -
      +
      {isEditMode ? (
      = memo( Revert to checkpoint? This will revert your workflow to the state saved at this checkpoint.{' '} - This action cannot be undone. + + This action cannot be undone. + - - Cancel - + Cancel = memo( - - Cancel - + Cancel = memo( } }} > - { if (e.key === 'Enter') { e.preventDefault() // Trigger continue and revert - const revertButton = e.currentTarget.querySelector('[data-action="revert"]') as HTMLButtonElement + const revertButton = e.currentTarget.querySelector( + '[data-action="revert"]' + ) as HTMLButtonElement revertButton?.click() } }} @@ -886,12 +897,12 @@ const CopilotMessage: FC = memo( pendingEditRef.current = null } }} - className='inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-muted px-4 py-2 font-medium text-sm text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' + className='inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-muted px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' > Continue From ddf4cd4bd2729785dbdaa11007eb665b67519815 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Oct 2025 10:08:25 -0700 Subject: [PATCH 17/28] Revert popup improvements: gray out stuff below, show cursor on revert --- .../copilot-message/copilot-message.tsx | 363 ++++++++++-------- .../components/user-input/user-input.tsx | 37 +- .../panel/components/copilot/copilot.tsx | 18 +- 3 files changed, 248 insertions(+), 170 deletions(-) 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 971cc5869a7..16d16fbe984 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 @@ -8,7 +8,6 @@ import { Box, Check, Clipboard, - CornerDownLeft, Info, LibraryBig, RotateCcw, @@ -19,16 +18,6 @@ import { Workflow, X, } from 'lucide-react' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui' import { InlineToolCall } from '@/lib/copilot/inline-tool-call' import { createLogger } from '@/lib/logs/console/logger' import { @@ -55,6 +44,7 @@ interface CopilotMessageProps { isDimmed?: boolean checkpointCount?: number onEditModeChange?: (isEditing: boolean) => void + onRevertModeChange?: (isReverting: boolean) => void } const CopilotMessage: FC = memo( @@ -65,6 +55,7 @@ const CopilotMessage: FC = memo( isDimmed = false, checkpointCount = 0, onEditModeChange, + onRevertModeChange, }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' @@ -289,6 +280,7 @@ const CopilotMessage: FC = memo( const handleRevertToCheckpoint = () => { setShowRestoreConfirmation(true) + onRevertModeChange?.(true) } const handleConfirmRevert = async () => { @@ -306,7 +298,50 @@ const CopilotMessage: FC = memo( } useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) + // Truncate all messages after this point + const currentMessages = messages + const revertIndex = currentMessages.findIndex((m) => m.id === message.id) + if (revertIndex !== -1) { + const truncatedMessages = currentMessages.slice(0, revertIndex + 1) + useCopilotStore.setState({ messages: truncatedMessages }) + + // Update DB to remove messages after this point + if (currentChat?.id) { + try { + await fetch('/api/copilot/chat/update-messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: currentChat.id, + messages: truncatedMessages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + timestamp: m.timestamp, + ...(m.contentBlocks && { contentBlocks: m.contentBlocks }), + ...(m.fileAttachments && { fileAttachments: m.fileAttachments }), + ...((m as any).contexts && { contexts: (m as any).contexts }), + })), + }), + }) + } catch (error) { + logger.error('Failed to update messages in DB after revert:', error) + } + } + } + setShowRestoreConfirmation(false) + onRevertModeChange?.(false) + + // Enter edit mode after reverting + setIsEditMode(true) + onEditModeChange?.(true) + + // Focus the input after render + setTimeout(() => { + userInputRef.current?.focus() + }, 100) + logger.info('Checkpoint reverted and removed from message', { messageId: message.id, checkpointId: latestCheckpoint.id, @@ -314,18 +349,22 @@ const CopilotMessage: FC = memo( } catch (error) { logger.error('Failed to revert to checkpoint:', error) setShowRestoreConfirmation(false) + onRevertModeChange?.(false) } } } const handleCancelRevert = () => { setShowRestoreConfirmation(false) + onRevertModeChange?.(false) } const handleEditMessage = () => { setIsEditMode(true) setIsExpanded(false) setEditedContent(message.content) + setShowRestoreConfirmation(false) // Dismiss any open confirmation popup + onRevertModeChange?.(false) // Notify parent onEditModeChange?.(true) // Focus the input and position cursor at the end after render setTimeout(() => { @@ -467,6 +506,68 @@ const CopilotMessage: FC = memo( } }, [showDownvoteSuccess]) + // Handle Escape key to close restore confirmation + useEffect(() => { + if (!showRestoreConfirmation) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowRestoreConfirmation(false) + onRevertModeChange?.(false) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [showRestoreConfirmation, onRevertModeChange]) + + // Handle Escape and Enter keys for checkpoint discard confirmation + useEffect(() => { + if (!showCheckpointDiscardModal) return + + const handleKeyDown = async (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowCheckpointDiscardModal(false) + pendingEditRef.current = null + } else if (event.key === 'Enter') { + event.preventDefault() + // Trigger "Continue and revert" action on Enter + if (messageCheckpoints.length > 0) { + const latestCheckpoint = messageCheckpoints[0] + try { + await revertToCheckpoint(latestCheckpoint.id) + + // Remove the used checkpoint from the store + const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() + const updatedCheckpoints = { + ...currentCheckpoints, + [message.id]: messageCheckpoints.slice(1), + } + useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) + + logger.info('Reverted to checkpoint before editing message', { + messageId: message.id, + checkpointId: latestCheckpoint.id, + }) + } catch (error) { + logger.error('Failed to revert to checkpoint:', error) + } + } + + setShowCheckpointDiscardModal(false) + + if (pendingEditRef.current) { + const { message: msg, fileAttachments, contexts } = pendingEditRef.current + await performEdit(msg, fileAttachments, contexts) + pendingEditRef.current = null + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [showCheckpointDiscardModal, messageCheckpoints, message.id]) + // Handle click outside to exit edit mode useEffect(() => { if (!isEditMode) return @@ -625,6 +726,7 @@ const CopilotMessage: FC = memo( onSubmit={handleSubmitEdit} onAbort={handleCancelEdit} isLoading={isSendingMessage && isLastUserMessage} + disabled={showCheckpointDiscardModal} value={editedContent} onChange={setEditedContent} placeholder='Edit your message...' @@ -632,7 +734,84 @@ const CopilotMessage: FC = memo( onModeChange={setMode} panelWidth={panelWidth} hideContextUsage={true} + clearOnSubmit={false} /> + + {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} + {showCheckpointDiscardModal && ( +
      +

      Continue from a previous message?

      +
      + + + +
      +
      + )}
      ) : (
      @@ -802,7 +981,7 @@ const CopilotMessage: FC = memo(
      )} - {/* Restore Checkpoint Confirmation Modal */} - - - - Revert to checkpoint? - - This will revert your workflow to the state saved at this checkpoint.{' '} - - This action cannot be undone. - - - - - Cancel - - Revert - - - - - - {/* Restore Checkpoint Confirmation Modal */} - - - - Revert to checkpoint? - - This will revert your workflow to the state saved at this checkpoint. - - This action cannot be undone. - - - - - Cancel - - Revert - - - - - - {/* Checkpoint Discard Confirmation Modal */} - { - setShowCheckpointDiscardModal(open) - if (!open) { - pendingEditRef.current = null - } - }} - > - { - if (e.key === 'Enter') { - e.preventDefault() - // Trigger continue and revert - const revertButton = e.currentTarget.querySelector( - '[data-action="revert"]' - ) as HTMLButtonElement - revertButton?.click() - } - }} - > - - Continue from a previous message? - - - + {/* Inline Restore Checkpoint Confirmation */} + {showRestoreConfirmation && ( +
      +

      + 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/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 218cbf2a000..f0882a7abba 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 @@ -96,6 +96,7 @@ interface UserInputProps { onChange?: (value: string) => void // Callback when value changes panelWidth?: number // Panel width to adjust truncation hideContextUsage?: boolean // Hide the context usage pill + clearOnSubmit?: boolean // Whether to clear input after submit (default true for bottom input, false for edit mode) } interface UserInputRef { @@ -118,6 +119,7 @@ const UserInput = forwardRef( onChange: onControlledChange, panelWidth = 308, hideContextUsage = false, + clearOnSubmit = true, }, ref ) => { @@ -678,25 +680,28 @@ const UserInput = forwardRef( // Send only the explicitly selected contexts onSubmit(trimmedMessage, fileAttachments, selectedContexts as any) - // Clean up preview URLs before clearing - attachedFiles.forEach((f) => { - if (f.previewUrl) { - URL.revokeObjectURL(f.previewUrl) - } - }) + // Only clear after submit if clearOnSubmit is true (default behavior for bottom input) + if (clearOnSubmit) { + // Clean up preview URLs before clearing + attachedFiles.forEach((f) => { + if (f.previewUrl) { + URL.revokeObjectURL(f.previewUrl) + } + }) - // Clear the message and files after submit - if (controlledValue !== undefined) { - onControlledChange?.('') - } else { - setInternalMessage('') - } - setAttachedFiles([]) + // Clear the message and files after submit + if (controlledValue !== undefined) { + onControlledChange?.('') + } else { + setInternalMessage('') + } + setAttachedFiles([]) - // Clear @mention contexts after submission - setSelectedContexts([]) + // Clear @mention contexts after submission + setSelectedContexts([]) - setOpenSubmenuFor(null) + setOpenSubmenuFor(null) + } setShowMentionMenu(false) } 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 c113bc2c577..76f139b86aa 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 @@ -58,6 +58,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref const hasLoadedModelsRef = useRef(false) const [editingMessageId, setEditingMessageId] = useState(null) const [isEditingMessage, setIsEditingMessage] = useState(false) + const [revertingMessageId, setRevertingMessageId] = useState(null) // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) @@ -453,6 +454,10 @@ export const Copilot = forwardRef(({ panelWidth }, ref logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing }) }, []) + const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => { + setRevertingMessageId(isReverting ? messageId : null) + }, []) + return ( <>
      @@ -482,12 +487,20 @@ export const Copilot = forwardRef(({ panelWidth }, ref
      ) : ( messages.map((message, index) => { - // Determine if this message should be dimmed (comes after the message being edited) + // 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) + 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 @@ -505,6 +518,9 @@ export const Copilot = forwardRef(({ panelWidth }, ref onEditModeChange={(isEditing) => handleEditModeChange(message.id, isEditing) } + onRevertModeChange={(isReverting) => + handleRevertModeChange(message.id, isReverting) + } /> ) }) From 81f5b2be968da1deb55529e572263b5555cb07ce Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Oct 2025 10:08:39 -0700 Subject: [PATCH 18/28] Fix lint --- .../copilot-message/copilot-message.tsx | 33 ++++++++++--------- .../panel/components/copilot/copilot.tsx | 8 +++-- apps/sim/lib/copilot/inline-tool-call.tsx | 9 +++-- .../client/user/set-environment-variables.ts | 2 +- apps/sim/stores/copilot/store.ts | 8 ++--- 5 files changed, 31 insertions(+), 29 deletions(-) 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 16d16fbe984..3ca10fdfcf1 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 @@ -304,7 +304,7 @@ const CopilotMessage: FC = memo( if (revertIndex !== -1) { const truncatedMessages = currentMessages.slice(0, revertIndex + 1) useCopilotStore.setState({ messages: truncatedMessages }) - + // Update DB to remove messages after this point if (currentChat?.id) { try { @@ -332,16 +332,16 @@ const CopilotMessage: FC = memo( setShowRestoreConfirmation(false) onRevertModeChange?.(false) - + // Enter edit mode after reverting setIsEditMode(true) onEditModeChange?.(true) - + // Focus the input after render setTimeout(() => { userInputRef.current?.focus() }, 100) - + logger.info('Checkpoint reverted and removed from message', { messageId: message.id, checkpointId: latestCheckpoint.id, @@ -380,7 +380,7 @@ const CopilotMessage: FC = memo( const handleMessageClick = () => { // Allow entering edit mode even while streaming - + // If message needs expansion and is not expanded, expand it if (needsExpansion && !isExpanded) { setIsExpanded(true) @@ -401,7 +401,7 @@ const CopilotMessage: FC = memo( if (isSendingMessage) { abortMessage() // Wait a brief moment for abort to complete - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) } // Check if this message has checkpoints @@ -434,7 +434,7 @@ const CopilotMessage: FC = memo( // Truncate messages after the edited message (but keep the edited message with updated content) const truncatedMessages = currentMessages.slice(0, editIndex) - + // Update the edited message with new content but keep it in the array const updatedMessage = { ...message, @@ -442,7 +442,7 @@ const CopilotMessage: FC = memo( fileAttachments: fileAttachments || message.fileAttachments, contexts: contexts || (message as any).contexts, } - + // Show the updated message immediately to prevent disappearing useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] }) @@ -736,18 +736,18 @@ const CopilotMessage: FC = memo( hideContextUsage={true} clearOnSubmit={false} /> - + {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} {showCheckpointDiscardModal && (
      -

      Continue from a previous message?

      +

      Continue from a previous message?

      @@ -805,7 +805,7 @@ const CopilotMessage: FC = memo( pendingEditRef.current = null } }} - className='flex-1 rounded-md bg-[var(--brand-primary-hover-hex)] px-2 py-1 text-xs text-white transition-colors hover:bg-[var(--brand-primary-hex)]' + className='flex-1 rounded-md bg-[var(--brand-primary-hover-hex)] px-2 py-1 text-white text-xs transition-colors hover:bg-[var(--brand-primary-hex)]' > Continue and revert @@ -997,8 +997,9 @@ const CopilotMessage: FC = memo( {/* Inline Restore Checkpoint Confirmation */} {showRestoreConfirmation && (
      -

      - Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '} +

      + Revert to checkpoint? This will restore your workflow to the state saved at this + checkpoint.{' '} This action cannot be undone. @@ -1006,13 +1007,13 @@ const CopilotMessage: FC = memo(

      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 76f139b86aa..35158b30811 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 @@ -489,16 +489,18 @@ export const Copilot = forwardRef(({ panelWidth }, ref 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) + const revertingIndex = messages.findIndex( + (m) => m.id === revertingMessageId + ) isDimmed = revertingIndex !== -1 && index > revertingIndex } diff --git a/apps/sim/lib/copilot/inline-tool-call.tsx b/apps/sim/lib/copilot/inline-tool-call.tsx index 3e5ea2f4f32..d89e1950f01 100644 --- a/apps/sim/lib/copilot/inline-tool-call.tsx +++ b/apps/sim/lib/copilot/inline-tool-call.tsx @@ -326,7 +326,7 @@ export function InlineToolCall({ if (toolCall.name === 'set_environment_variables') { const variables = params.variables && typeof params.variables === 'object' ? params.variables : {} - + // Normalize variables - handle both direct key-value and nested {name, value} format const normalizedEntries: Array<[string, string]> = [] Object.entries(variables).forEach(([key, value]) => { @@ -338,7 +338,7 @@ export function InlineToolCall({ normalizedEntries.push([key, String(value)]) } }) - + return (
      @@ -354,7 +354,10 @@ export function InlineToolCall({ ) : (
      {normalizedEntries.map(([name, value]) => ( -
      +
      {name}
      diff --git a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts index 0e5f80abc58..1c0dca81602 100644 --- a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts @@ -78,7 +78,7 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool { this.setState(ClientToolCallState.success) await this.markToolComplete(200, 'Environment variables updated', parsed.result) this.setState(ClientToolCallState.success) - + // Refresh the environment store so the UI reflects the new variables try { await useEnvironmentStore.getState().loadEnvironmentVariables() diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 769e967267f..036d5cbaea4 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1573,14 +1573,10 @@ export const useCopilotStore = create()( } else { const currentMessages = get().messages // If messageId is provided, check if it already exists (e.g., from edit flow) - const existingIndex = messageId ? currentMessages.findIndex(m => m.id === messageId) : -1 + const existingIndex = messageId ? currentMessages.findIndex((m) => m.id === messageId) : -1 if (existingIndex !== -1) { // Replace existing message instead of adding new one - newMessages = [ - ...currentMessages.slice(0, existingIndex), - userMessage, - streamingMessage - ] + newMessages = [...currentMessages.slice(0, existingIndex), userMessage, streamingMessage] } else { // Add new messages normally newMessages = [...currentMessages, userMessage, streamingMessage] From e02db18c26cca80dac98bc5f727244ff7f224636 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Oct 2025 10:22:31 -0700 Subject: [PATCH 19/28] Improve chat history dropdown --- apps/sim/app/api/copilot/chat/delete/route.ts | 36 +++++ .../api/copilot/chat/update-title/route.ts | 46 +++++++ .../w/[workflowId]/components/panel/panel.tsx | 130 ++++++++++++++---- apps/sim/stores/copilot/store.ts | 28 +++- 4 files changed, 210 insertions(+), 30 deletions(-) create mode 100644 apps/sim/app/api/copilot/chat/delete/route.ts create mode 100644 apps/sim/app/api/copilot/chat/update-title/route.ts diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts new file mode 100644 index 00000000000..1f24980657c --- /dev/null +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -0,0 +1,36 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('DeleteChatAPI') + +const DeleteChatSchema = z.object({ + chatId: z.string(), +}) + +export async function DELETE(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const parsed = DeleteChatSchema.parse(body) + + // Delete the chat + await db.delete(copilotChats).where(eq(copilotChats.id, parsed.chatId)) + + logger.info('Chat deleted', { chatId: parsed.chatId }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting chat:', error) + return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 }) + } +} + diff --git a/apps/sim/app/api/copilot/chat/update-title/route.ts b/apps/sim/app/api/copilot/chat/update-title/route.ts new file mode 100644 index 00000000000..4fe6bd93da5 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/update-title/route.ts @@ -0,0 +1,46 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('UpdateChatTitleAPI') + +const UpdateTitleSchema = z.object({ + chatId: z.string(), + title: z.string(), +}) + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const parsed = UpdateTitleSchema.parse(body) + + // Update the chat title + await db + .update(copilotChats) + .set({ + title: parsed.title, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, parsed.chatId)) + + logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error updating chat title:', error) + return NextResponse.json( + { success: false, error: 'Failed to update chat title' }, + { status: 500 } + ) + } +} + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index e0d2a7a51c0..2bfef8525bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowDownToLine, CircleSlash, History, Plus, X } from 'lucide-react' +import { ArrowDownToLine, CircleSlash, History, Pencil, Plus, Trash2, X } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -26,6 +26,8 @@ const logger = createLogger('Panel') export function Panel() { const [chatMessage, setChatMessage] = useState('') const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false) + const [editingChatId, setEditingChatId] = useState(null) + const [editingChatTitle, setEditingChatTitle] = useState('') const [isResizing, setIsResizing] = useState(false) const [resizeStartX, setResizeStartX] = useState(0) @@ -432,55 +434,127 @@ export function Panel() { {isLoadingChats ? ( - + ) : groupedChats.length === 0 ? ( -
      No chats yet
      +
      No chats yet
      ) : ( - + {groupedChats.map(([groupName, chats], groupIndex) => (
      {groupName}
      -
      +
      {chats.map((chat) => (
      { - // Only call selectChat if it's a different chat - // This prevents aborting streams when clicking the currently active chat - if (currentChat?.id !== chat.id) { - selectChat(chat) - } - setIsHistoryDropdownOpen(false) - }} - className={`group mx-1 flex h-8 cursor-pointer items-center rounded-lg px-2 py-1.5 text-left transition-colors ${ + className={`group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${ currentChat?.id === chat.id - ? 'bg-accent' - : 'hover:bg-accent/50' + ? 'bg-accent text-accent-foreground' + : 'hover:bg-accent/50 text-foreground' }`} - style={{ width: '176px', maxWidth: '176px' }} > - - {chat.title || 'Untitled Chat'} - + {editingChatId === chat.id ? ( + setEditingChatTitle(e.target.value)} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + e.preventDefault() + const newTitle = editingChatTitle.trim() || 'Untitled Chat' + + // Update optimistically in store first + const updatedChats = chats.map((c) => + c.id === chat.id ? { ...c, title: newTitle } : c + ) + useCopilotStore.setState({ chats: updatedChats }) + + // Exit edit mode immediately + setEditingChatId(null) + + // Save to database in background + try { + await fetch('/api/copilot/chat/update-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: chat.id, + title: newTitle, + }), + }) + } catch (error) { + logger.error('Failed to update chat title:', error) + // Revert on error + await loadChats(true) + } + } else if (e.key === 'Escape') { + setEditingChatId(null) + } + }} + onBlur={() => setEditingChatId(null)} + autoFocus + className='min-w-0 flex-1 rounded border-none bg-transparent px-0 text-sm outline-none focus:outline-none' + /> + ) : ( + <> + { + // Only call selectChat if it's a different chat + if (currentChat?.id !== chat.id) { + selectChat(chat) + } + setIsHistoryDropdownOpen(false) + }} + className='min-w-0 cursor-pointer truncate text-sm' + style={{ maxWidth: 'calc(100% - 60px)' }} + > + {chat.title || 'Untitled Chat'} + +
      + + +
      + + )}
      ))}
      diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 036d5cbaea4..5d13b8fad28 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1468,8 +1468,32 @@ export const useCopilotStore = create()( }) }, - deleteChat: async (_chatId: string) => { - // no-op for now + deleteChat: async (chatId: string) => { + try { + // Call delete API + const response = await fetch('/api/copilot/chat/delete', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId }), + }) + + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.status}`) + } + + // Remove from local state + set((state) => ({ + chats: state.chats.filter((c) => c.id !== chatId), + // If deleted chat was current, clear it + currentChat: state.currentChat?.id === chatId ? null : state.currentChat, + messages: state.currentChat?.id === chatId ? [] : state.messages, + })) + + logger.info('Chat deleted', { chatId }) + } catch (error) { + logger.error('Failed to delete chat:', error) + throw error + } }, areChatsFresh: (_workflowId: string) => false, From 89c143b21d9eb5b9f92935cfac9b4eaae5cd22e7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Oct 2025 11:34:45 -0700 Subject: [PATCH 20/28] Improve get block metadata tool --- .../copilot-message/copilot-message.tsx | 4 +- .../server/blocks/get-blocks-metadata-tool.ts | 407 +++++++++++++++++- 2 files changed, 395 insertions(+), 16 deletions(-) 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 3ca10fdfcf1..be61623e2b4 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 @@ -909,7 +909,7 @@ const CopilotMessage: FC = memo( >
      = memo( {/* Abort button when hovering and response is generating (only on last user message) */} {isSendingMessage && isHoveringMessage && isLastUserMessage && ( -
      +
      ) : groupedChats.length === 0 ? ( -
      No chats yet
      +
      + No chats yet +
      ) : (
      {groupedChats.map(([groupName, chats], groupIndex) => ( @@ -462,7 +463,7 @@ export function Panel() { className={`group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${ currentChat?.id === chat.id ? 'bg-accent text-accent-foreground' - : 'hover:bg-accent/50 text-foreground' + : 'text-foreground hover:bg-accent/50' }`} > {editingChatId === chat.id ? ( @@ -473,17 +474,18 @@ export function Panel() { onKeyDown={async (e) => { if (e.key === 'Enter') { e.preventDefault() - const newTitle = editingChatTitle.trim() || 'Untitled Chat' - + const newTitle = + editingChatTitle.trim() || 'Untitled Chat' + // Update optimistically in store first const updatedChats = chats.map((c) => c.id === chat.id ? { ...c, title: newTitle } : c ) useCopilotStore.setState({ chats: updatedChats }) - + // Exit edit mode immediately setEditingChatId(null) - + // Save to database in background try { await fetch('/api/copilot/chat/update-title', { @@ -504,7 +506,6 @@ export function Panel() { } }} onBlur={() => setEditingChatId(null)} - autoFocus className='min-w-0 flex-1 rounded border-none bg-transparent px-0 text-sm outline-none focus:outline-none' /> ) : ( @@ -536,13 +537,13 @@ export function Panel() {
      @@ -1007,15 +1013,17 @@ const CopilotMessage: FC = memo(
      diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index dc851e82e75..a4fe0b05d9e 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1738,6 +1738,14 @@ export const useCopilotStore = create()( }).catch(() => {}) } catch {} } + + // Fetch context usage after abort + logger.info('[Context Usage] Message aborted, fetching usage') + get() + .fetchContextUsage() + .catch((err) => { + logger.warn('[Context Usage] Failed to fetch after abort', err) + }) } catch { set({ isSendingMessage: false, isAborting: false, abortController: null }) } From 763986562d5533bd78c29473fded1003b116eb67 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 17 Oct 2025 16:56:01 -0700 Subject: [PATCH 27/28] Fix lint --- apps/sim/lib/sim-agent/constants.ts | 2 +- apps/sim/stores/copilot/store.ts | 140 ++++++++++++++++------------ 2 files changed, 83 insertions(+), 59 deletions(-) diff --git a/apps/sim/lib/sim-agent/constants.ts b/apps/sim/lib/sim-agent/constants.ts index 22e07c22c13..996615278f5 100644 --- a/apps/sim/lib/sim-agent/constants.ts +++ b/apps/sim/lib/sim-agent/constants.ts @@ -1,2 +1,2 @@ export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai' -export const SIM_AGENT_VERSION = '1.0.1' +export const SIM_AGENT_VERSION = '1.0.2' diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index a4fe0b05d9e..2ff0985f573 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -291,67 +291,76 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] { // Use existing contentBlocks ordering if present; otherwise only render text content const blocks: any[] = Array.isArray(message.contentBlocks) - ? (message.contentBlocks as any[]).map((b: any) => - b?.type === 'tool_call' && b.toolCall - ? { - ...b, - toolCall: { - ...b.toolCall, - state: - isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? b.toolCall.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - b.toolCall?.name, - (isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? (b.toolCall?.state as any) - : ClientToolCallState.rejected) as any, - b.toolCall?.id, - b.toolCall?.params - ), - }, - } - : b - ) + ? (message.contentBlocks as any[]).map((b: any) => { + if (b?.type === 'tool_call' && b.toolCall) { + // Ensure client tool instance is registered for this tool call + ensureClientToolInstance(b.toolCall?.name, b.toolCall?.id) + + return { + ...b, + toolCall: { + ...b.toolCall, + state: + isRejectedState(b.toolCall?.state) || + isReviewState(b.toolCall?.state) || + isBackgroundState(b.toolCall?.state) || + b.toolCall?.state === ClientToolCallState.success || + b.toolCall?.state === ClientToolCallState.error || + b.toolCall?.state === ClientToolCallState.aborted + ? b.toolCall.state + : ClientToolCallState.rejected, + display: resolveToolDisplay( + b.toolCall?.name, + (isRejectedState(b.toolCall?.state) || + isReviewState(b.toolCall?.state) || + isBackgroundState(b.toolCall?.state) || + b.toolCall?.state === ClientToolCallState.success || + b.toolCall?.state === ClientToolCallState.error || + b.toolCall?.state === ClientToolCallState.aborted + ? (b.toolCall?.state as any) + : ClientToolCallState.rejected) as any, + b.toolCall?.id, + b.toolCall?.params + ), + }, + } + } + return b + }) : [] // Prepare toolCalls with display for non-block UI components, but do not fabricate blocks const updatedToolCalls = Array.isArray((message as any).toolCalls) - ? (message as any).toolCalls.map((tc: any) => ({ - ...tc, - state: - isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? tc.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - tc?.name, - (isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? (tc?.state as any) - : ClientToolCallState.rejected) as any, - tc?.id, - tc?.params - ), - })) + ? (message as any).toolCalls.map((tc: any) => { + // Ensure client tool instance is registered for this tool call + ensureClientToolInstance(tc?.name, tc?.id) + + return { + ...tc, + state: + isRejectedState(tc?.state) || + isReviewState(tc?.state) || + isBackgroundState(tc?.state) || + tc?.state === ClientToolCallState.success || + tc?.state === ClientToolCallState.error || + tc?.state === ClientToolCallState.aborted + ? tc.state + : ClientToolCallState.rejected, + display: resolveToolDisplay( + tc?.name, + (isRejectedState(tc?.state) || + isReviewState(tc?.state) || + isBackgroundState(tc?.state) || + tc?.state === ClientToolCallState.success || + tc?.state === ClientToolCallState.error || + tc?.state === ClientToolCallState.aborted + ? (tc?.state as any) + : ClientToolCallState.rejected) as any, + tc?.id, + tc?.params + ), + } + }) : (message as any).toolCalls return { @@ -1399,13 +1408,28 @@ export const useCopilotStore = create()( if (data.success && Array.isArray(data.chats)) { const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id) if (latestChat) { + const normalizedMessages = normalizeMessagesForUI(latestChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: latestChat, - messages: normalizeMessagesForUI(latestChat.messages || []), + messages: normalizedMessages, chats: (get().chats || []).map((c: CopilotChat) => c.id === chat.id ? latestChat : c ), contextUsage: null, + toolCallsById, }) try { await get().loadMessageCheckpoints(latestChat.id) From 3f6ca3994ec9987e996cb2b3f8331c4429eefe28 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 17 Oct 2025 17:07:04 -0700 Subject: [PATCH 28/28] Fix tests and lint --- apps/sim/app/api/copilot/chat/route.test.ts | 46 ++++----------------- apps/sim/stores/copilot/store.ts | 34 ++++++++++++++- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.test.ts b/apps/sim/app/api/copilot/chat/route.test.ts index f2ae1103b33..d2c00d47be7 100644 --- a/apps/sim/app/api/copilot/chat/route.test.ts +++ b/apps/sim/app/api/copilot/chat/route.test.ts @@ -214,18 +214,7 @@ describe('Copilot Chat API Route', () => { 'x-api-key': 'test-sim-agent-key', }, body: JSON.stringify({ - messages: [ - { - role: 'user', - content: 'Hello', - }, - ], - chatMessages: [ - { - role: 'user', - content: 'Hello', - }, - ], + message: 'Hello', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -233,7 +222,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -286,16 +275,7 @@ describe('Copilot Chat API Route', () => { 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [ - { role: 'user', content: 'Previous message' }, - { role: 'assistant', content: 'Previous response' }, - { role: 'user', content: 'New message' }, - ], - chatMessages: [ - { role: 'user', content: 'Previous message' }, - { role: 'assistant', content: 'Previous response' }, - { role: 'user', content: 'New message' }, - ], + message: 'New message', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -303,7 +283,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -341,19 +321,12 @@ describe('Copilot Chat API Route', () => { const { POST } = await import('@/app/api/copilot/chat/route') await POST(req) - // Verify implicit feedback was included as system message + // Verify implicit feedback was included expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [ - { role: 'system', content: 'User seems confused about the workflow' }, - { role: 'user', content: 'Hello' }, - ], - chatMessages: [ - { role: 'system', content: 'User seems confused about the workflow' }, - { role: 'user', content: 'Hello' }, - ], + message: 'Hello', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -361,7 +334,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -444,8 +417,7 @@ describe('Copilot Chat API Route', () => { 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [{ role: 'user', content: 'What is this workflow?' }], - chatMessages: [{ role: 'user', content: 'What is this workflow?' }], + message: 'What is this workflow?', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -453,7 +425,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'ask', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 2ff0985f573..615b1926061 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1544,9 +1544,24 @@ export const useCopilotStore = create()( if (isSendingMessage) { set({ currentChat: { ...updatedCurrentChat, messages: get().messages } }) } else { + const normalizedMessages = normalizeMessagesForUI(updatedCurrentChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: updatedCurrentChat, - messages: normalizeMessagesForUI(updatedCurrentChat.messages || []), + messages: normalizedMessages, + toolCallsById, }) } try { @@ -1554,9 +1569,24 @@ export const useCopilotStore = create()( } catch {} } else if (!isSendingMessage && !suppressAutoSelect) { const mostRecentChat: CopilotChat = data.chats[0] + const normalizedMessages = normalizeMessagesForUI(mostRecentChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: mostRecentChat, - messages: normalizeMessagesForUI(mostRecentChat.messages || []), + messages: normalizedMessages, + toolCallsById, }) try { await get().loadMessageCheckpoints(mostRecentChat.id)