From 23b3dacd1ab7533d24ae3f466aa80e84ea28102f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 12 Jan 2026 19:55:56 -0800 Subject: [PATCH 01/16] Slash commands v0 --- .../components/user-input/components/index.ts | 1 + .../components/slash-menu/slash-menu.tsx | 169 ++++++++++++++++++ .../hooks/use-context-management.ts | 26 +-- .../user-input/hooks/use-mention-menu.ts | 92 ++++++++++ .../user-input/hooks/use-mention-tokens.ts | 11 +- .../components/user-input/user-input.tsx | 145 ++++++++++++++- apps/sim/stores/panel/copilot/types.ts | 1 + 7 files changed, 426 insertions(+), 19 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts index fd7d64cff1..bab808a85b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts @@ -3,3 +3,4 @@ export { ContextPills } from './context-pills/context-pills' export { MentionMenu } from './mention-menu/mention-menu' export { ModeSelector } from './mode-selector/mode-selector' export { ModelSelector } from './model-selector/model-selector' +export { SlashMenu } from './slash-menu/slash-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx new file mode 100644 index 0000000000..97b339ea5d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useMemo } from 'react' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, + PopoverScrollArea, +} from '@/components/emcn' +import type { useMentionMenu } from '../../hooks/use-mention-menu' + +/** + * Slash command options + */ +const SLASH_COMMANDS = [ + { id: 'plan', label: 'plan' }, + { id: 'debug', label: 'debug' }, + { id: 'fast', label: 'fast' }, + { id: 'superagent', label: 'superagent' }, + { id: 'research', label: 'research' }, + { id: 'deploy', label: 'deploy' }, +] as const + +interface SlashMenuProps { + mentionMenu: ReturnType + message: string + onSelectCommand: (command: string) => void +} + +/** + * SlashMenu component for slash command dropdown. + * Shows command options when user types '/'. + * + * @param props - Component props + * @returns Rendered slash menu + */ +export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) { + const { mentionMenuRef, menuListRef, getActiveSlashQueryAtPosition, getCaretPos, submenuActiveIndex } = + mentionMenu + + /** + * Get the current query string after / + */ + const currentQuery = useMemo(() => { + const caretPos = getCaretPos() + const active = getActiveSlashQueryAtPosition(caretPos, message) + return active?.query.trim().toLowerCase() || '' + }, [message, getCaretPos, getActiveSlashQueryAtPosition]) + + /** + * Filter commands based on query + */ + const filteredCommands = useMemo(() => { + if (!currentQuery) return SLASH_COMMANDS + return SLASH_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery)) + }, [currentQuery]) + + // Compute caret viewport position via mirror technique for precise anchoring + const textareaEl = mentionMenu.textareaRef.current + if (!textareaEl) return null + + const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => { + const textareaRect = textarea.getBoundingClientRect() + const style = window.getComputedStyle(textarea) + + const mirrorDiv = document.createElement('div') + mirrorDiv.style.position = 'absolute' + mirrorDiv.style.visibility = 'hidden' + mirrorDiv.style.whiteSpace = 'pre-wrap' + mirrorDiv.style.wordWrap = 'break-word' + mirrorDiv.style.font = style.font + mirrorDiv.style.padding = style.padding + mirrorDiv.style.border = style.border + mirrorDiv.style.width = style.width + mirrorDiv.style.lineHeight = style.lineHeight + mirrorDiv.style.boxSizing = style.boxSizing + mirrorDiv.style.letterSpacing = style.letterSpacing + mirrorDiv.style.textTransform = style.textTransform + mirrorDiv.style.textIndent = style.textIndent + mirrorDiv.style.textAlign = style.textAlign + + mirrorDiv.textContent = text.substring(0, caretPosition) + + const caretMarker = document.createElement('span') + caretMarker.style.display = 'inline-block' + caretMarker.style.width = '0px' + caretMarker.style.padding = '0' + caretMarker.style.border = '0' + mirrorDiv.appendChild(caretMarker) + + document.body.appendChild(mirrorDiv) + const markerRect = caretMarker.getBoundingClientRect() + const mirrorRect = mirrorDiv.getBoundingClientRect() + document.body.removeChild(mirrorDiv) + + const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft + const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop + + return { + left: textareaRect.left + leftOffset, + top: textareaRect.top + topOffset, + } + } + + const caretPos = getCaretPos() + const caretViewport = getCaretViewport(textareaEl, caretPos, message) + + // Decide preferred side based on available space + const margin = 8 + const spaceAbove = caretViewport.top - margin + const spaceBelow = window.innerHeight - caretViewport.top - margin + const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top' + + return ( + { + /* controlled externally */ + }} + > + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + {filteredCommands.length === 0 ? ( +
+ No commands found +
+ ) : ( + filteredCommands.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + )) + )} +
+
+ + ) +} + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 72aa6067ca..6ec97ddbfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -63,6 +63,9 @@ export function useContextManagement({ message }: UseContextManagementProps) { if (c.kind === 'docs') { return true // Only one docs context allowed } + if (c.kind === 'slash_command' && 'command' in context && 'command' in c) { + return c.command === (context as any).command + } } return false @@ -103,6 +106,8 @@ export function useContextManagement({ message }: UseContextManagementProps) { return (c as any).executionId !== (contextToRemove as any).executionId case 'docs': return false // Remove docs (only one docs context) + case 'slash_command': + return (c as any).command !== (contextToRemove as any).command default: return c.label !== contextToRemove.label } @@ -118,7 +123,7 @@ export function useContextManagement({ message }: UseContextManagementProps) { }, []) /** - * Synchronizes selected contexts with inline @label tokens in the message. + * Synchronizes selected contexts with inline @label or /label tokens in the message. * Removes contexts whose labels are no longer present in the message. */ useEffect(() => { @@ -130,17 +135,14 @@ export function useContextManagement({ message }: UseContextManagementProps) { setSelectedContexts((prev) => { if (prev.length === 0) return prev - const presentLabels = new Set() - const labels = prev.map((c) => c.label).filter(Boolean) - - for (const label of labels) { - const token = ` @${label} ` - if (message.includes(token)) { - presentLabels.add(label) - } - } - - const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label)) + const filtered = prev.filter((c) => { + if (!c.label) return false + // Check for slash command tokens or mention tokens based on kind + const isSlashCommand = c.kind === 'slash_command' + const prefix = isSlashCommand ? '/' : '@' + const token = ` ${prefix}${c.label} ` + return message.includes(token) + }) return filtered.length === prev.length ? prev : filtered }) }, [message]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index 12460c060f..e069a2ce91 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -113,6 +113,62 @@ export function useMentionMenu({ [message, selectedContexts] ) + /** + * Finds active slash command query at the given position + * + * @param pos - Position in the text to check + * @param textOverride - Optional text override (for checking during input) + * @returns Active slash query object or null if no active slash command + */ + const getActiveSlashQueryAtPosition = useCallback( + (pos: number, textOverride?: string) => { + const text = textOverride ?? message + const before = text.slice(0, pos) + const slashIndex = before.lastIndexOf('/') + if (slashIndex === -1) return null + + // Ensure '/' starts a token (start or whitespace before) + if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null + + // Check if this '/' is part of a completed slash token ( /command ) + if (selectedContexts.length > 0) { + const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[] + for (const label of labels) { + // Space-wrapped token: " /label " + const token = ` /${label} ` + let fromIndex = 0 + while (fromIndex <= text.length) { + const idx = text.indexOf(token, fromIndex) + if (idx === -1) break + + const tokenStart = idx + const tokenEnd = idx + token.length + const slashPositionInToken = idx + 1 // position of / in " /label " + + if (slashIndex === slashPositionInToken) { + return null + } + + if (pos > tokenStart && pos < tokenEnd) { + return null + } + + fromIndex = tokenEnd + } + } + } + + const segment = before.slice(slashIndex + 1) + // Close the popup if user types space immediately after / + if (segment.length > 0 && /^\s/.test(segment)) { + return null + } + + return { query: segment, start: slashIndex, end: pos } + }, + [message, selectedContexts] + ) + /** * Gets the submenu query text * @@ -217,6 +273,40 @@ export function useMentionMenu({ [message, getActiveMentionQueryAtPosition, onMessageChange] ) + /** + * Replaces active slash command with a label + * + * @param label - Label to replace the slash command with + * @returns True if replacement was successful, false if no active slash command found + */ + const replaceActiveSlashWith = useCallback( + (label: string) => { + const textarea = textareaRef.current + if (!textarea) return false + const pos = textarea.selectionStart ?? message.length + const active = getActiveSlashQueryAtPosition(pos) + if (!active) return false + + const before = message.slice(0, active.start) + const after = message.slice(active.end) + + // Always include leading space, avoid duplicate if one exists + const needsLeadingSpace = !before.endsWith(' ') + const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} ` + + const next = `${before}${insertion}${after}` + onMessageChange(next) + + setTimeout(() => { + const cursorPos = before.length + insertion.length + textarea.setSelectionRange(cursorPos, cursorPos) + textarea.focus() + }, 0) + return true + }, + [message, getActiveSlashQueryAtPosition, onMessageChange] + ) + /** * Scrolls active item into view in the menu * @@ -304,10 +394,12 @@ export function useMentionMenu({ // Operations getCaretPos, getActiveMentionQueryAtPosition, + getActiveSlashQueryAtPosition, getSubmenuQuery, resetActiveMentionQuery, insertAtCursor, replaceActiveMentionWith, + replaceActiveSlashWith, scrollActiveItemIntoView, closeMentionMenu, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index ca76abe24d..2fbc5cf888 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -39,7 +39,7 @@ export function useMentionTokens({ setSelectedContexts, }: UseMentionTokensProps) { /** - * Computes all mention ranges in the message + * Computes all mention ranges in the message (both @mentions and /commands) * * @returns Array of mention ranges sorted by start position */ @@ -55,8 +55,13 @@ export function useMentionTokens({ const uniqueLabels = Array.from(new Set(labels)) for (const label of uniqueLabels) { - // Space-wrapped token: " @label " (search from start) - const token = ` @${label} ` + // Find matching context to determine if it's a slash command + const matchingContext = selectedContexts.find((c) => c.label === label) + const isSlashCommand = matchingContext?.kind === 'slash_command' + const prefix = isSlashCommand ? '/' : '@' + + // Space-wrapped token: " @label " or " /label " (search from start) + const token = ` ${prefix}${label} ` let fromIndex = 0 while (fromIndex <= message.length) { const idx = message.indexOf(token, fromIndex) 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 b8ad537e66..362b099068 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 @@ -21,6 +21,7 @@ import { MentionMenu, ModelSelector, ModeSelector, + SlashMenu, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import { @@ -123,6 +124,7 @@ const UserInput = forwardRef( const [isNearTop, setIsNearTop] = useState(false) const [containerRef, setContainerRef] = useState(null) const [inputContainerRef, setInputContainerRef] = useState(null) + const [showSlashMenu, setShowSlashMenu] = useState(false) // Controlled vs uncontrolled message state const message = controlledValue !== undefined ? controlledValue : internalMessage @@ -370,20 +372,70 @@ const UserInput = forwardRef( } }, [onAbort, isLoading]) + const handleSlashCommandSelect = useCallback( + (command: string) => { + // Capitalize the command for display + const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1) + + // Replace the active slash query with the capitalized command + mentionMenu.replaceActiveSlashWith(capitalizedCommand) + + // Add as a context so it gets highlighted + contextManagement.addContext({ + kind: 'slash_command', + command, + label: capitalizedCommand, + }) + + setShowSlashMenu(false) + mentionMenu.textareaRef.current?.focus() + }, + [mentionMenu, contextManagement] + ) + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // Escape key handling - if (e.key === 'Escape' && mentionMenu.showMentionMenu) { + if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) { e.preventDefault() if (mentionMenu.openSubmenuFor) { mentionMenu.setOpenSubmenuFor(null) mentionMenu.setSubmenuQueryStart(null) } else { mentionMenu.closeMentionMenu() + setShowSlashMenu(false) } return } + // Arrow navigation in slash menu + if (showSlashMenu) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + const SLASH_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy'] + const caretPos = mentionMenu.getCaretPos() + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) + const query = activeSlash?.query.trim().toLowerCase() || '' + const filtered = query + ? SLASH_COMMANDS.filter((cmd) => cmd.includes(query)) + : SLASH_COMMANDS + const last = Math.max(0, filtered.length - 1) + mentionMenu.setSubmenuActiveIndex((prev) => { + if (filtered.length === 0) return 0 + const next = + e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + return + } + // Prevent ArrowLeft/Right from moving cursor when slash menu is open + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault() + return + } + } + // Arrow navigation in mention menu if (mentionKeyboard.handleArrowNavigation(e)) return if (mentionKeyboard.handleArrowRight(e)) return @@ -392,6 +444,21 @@ const UserInput = forwardRef( // Enter key handling if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() + if (showSlashMenu) { + // Handle slash menu selection + const SLASH_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy'] + const caretPos = mentionMenu.getCaretPos() + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) + const query = activeSlash?.query.trim().toLowerCase() || '' + const filtered = query + ? SLASH_COMMANDS.filter((cmd) => cmd.includes(query)) + : SLASH_COMMANDS + if (filtered.length > 0) { + const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0] + handleSlashCommandSelect(selectedCommand) + } + return + } if (!mentionMenu.showMentionMenu) { handleSubmit() } else { @@ -469,7 +536,15 @@ const UserInput = forwardRef( } } }, - [mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext] + [ + mentionMenu, + mentionKeyboard, + handleSubmit, + handleSlashCommandSelect, + message, + mentionTokensWithContext, + showSlashMenu, + ] ) const handleInputChange = useCallback( @@ -481,9 +556,14 @@ const UserInput = forwardRef( if (disableMentions) return const caret = e.target.selectionStart ?? newValue.length - const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) - if (active) { + // Check for @ mention trigger + const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) + // Check for / slash command trigger + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue) + + if (activeMention) { + setShowSlashMenu(false) mentionMenu.setShowMentionMenu(true) mentionMenu.setInAggregated(false) if (mentionMenu.openSubmenuFor) { @@ -492,10 +572,17 @@ const UserInput = forwardRef( mentionMenu.setMentionActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0) } + } else if (activeSlash) { + mentionMenu.setShowMentionMenu(false) + mentionMenu.setOpenSubmenuFor(null) + mentionMenu.setSubmenuQueryStart(null) + setShowSlashMenu(true) + mentionMenu.setSubmenuActiveIndex(0) } else { mentionMenu.setShowMentionMenu(false) mentionMenu.setOpenSubmenuFor(null) mentionMenu.setSubmenuQueryStart(null) + setShowSlashMenu(false) } }, [setMessage, mentionMenu, disableMentions] @@ -542,6 +629,32 @@ const UserInput = forwardRef( mentionMenu.setSubmenuActiveIndex(0) }, [disabled, isLoading, mentionMenu, message, setMessage]) + const handleOpenSlashMenu = useCallback(() => { + if (disabled || isLoading) return + const textarea = mentionMenu.textareaRef.current + if (!textarea) return + textarea.focus() + const pos = textarea.selectionStart ?? message.length + const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1)) + + const insertText = needsSpaceBefore ? ' /' : '/' + const start = textarea.selectionStart ?? message.length + const end = textarea.selectionEnd ?? message.length + const before = message.slice(0, start) + const after = message.slice(end) + const next = `${before}${insertText}${after}` + setMessage(next) + + setTimeout(() => { + const newPos = before.length + insertText.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }, 0) + + setShowSlashMenu(true) + mentionMenu.setSubmenuActiveIndex(0) + }, [disabled, isLoading, mentionMenu, message, setMessage]) + const canSubmit = message.trim().length > 0 && !disabled && !isLoading const showAbortButton = isLoading && onAbort @@ -643,6 +756,18 @@ const UserInput = forwardRef( + + / + + {/* Selected Context Pills */} ( />, document.body )} + + {/* Slash Menu Portal */} + {!disableMentions && + showSlashMenu && + createPortal( + , + document.body + )}
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */} diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index fbb6404aac..0ddb9515f8 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -85,6 +85,7 @@ export type ChatContext = | { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'templates'; templateId?: string; label: string } | { kind: 'docs'; label: string } + | { kind: 'slash_command'; command: string; label: string } import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api' From 42e496f5ffb177b3c10dafba0a0b1050cc121053 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 12 Jan 2026 20:18:08 -0800 Subject: [PATCH 02/16] Web tools --- apps/sim/app/api/copilot/chat/route.ts | 3 + .../copilot-message/copilot-message.tsx | 16 ++++-- .../components/tool-call/tool-call.tsx | 23 +++++--- apps/sim/lib/copilot/api.ts | 1 + .../tools/client/other/crawl-website.ts | 54 ++++++++++++++++++ .../tools/client/other/get-page-contents.ts | 55 +++++++++++++++++++ .../copilot/tools/client/other/scrape-page.ts | 54 ++++++++++++++++++ .../tools/client/other/search-online.ts | 36 +----------- apps/sim/stores/panel/copilot/store.ts | 19 ++++++- 9 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/other/crawl-website.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/get-page-contents.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/scrape-page.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 8d92bdbb7b..77693eb3c9 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -96,6 +96,7 @@ const ChatMessageSchema = z.object({ }) ) .optional(), + commands: z.array(z.string()).optional(), }) /** @@ -131,6 +132,7 @@ export async function POST(req: NextRequest) { provider, conversationId, contexts, + commands, } = ChatMessageSchema.parse(body) // Ensure we have a consistent user message ID for this request const userMessageIdToUse = userMessageId || crypto.randomUUID() @@ -458,6 +460,7 @@ export async function POST(req: NextRequest) { ...(integrationTools.length > 0 && { tools: integrationTools }), ...(baseTools.length > 0 && { baseTools }), ...(credentials && { credentials }), + ...(commands && commands.length > 0 && { commands }), } try { 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 2cba10be86..3f7a6ef4cc 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 @@ -346,14 +346,18 @@ const CopilotMessage: FC = memo( const contexts: any[] = Array.isArray((message as any).contexts) ? ((message as any).contexts as any[]) : [] - const labels = contexts - .filter((c) => c?.kind !== 'current_workflow') - .map((c) => c?.label) - .filter(Boolean) as string[] - if (!labels.length) return text + + // Build tokens with their prefixes (@ for mentions, / for commands) + const tokens = contexts + .filter((c) => c?.kind !== 'current_workflow' && c?.label) + .map((c) => { + const prefix = c?.kind === 'slash_command' ? '/' : '@' + return `${prefix}${c.label}` + }) + if (!tokens.length) return text const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g') + const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g') const nodes: React.ReactNode[] = [] let lastIndex = 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 4f921c898e..cf2b9de231 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -2595,16 +2595,23 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: } } + // For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it) + const isEditWorkflow = toolCall.name === 'edit_workflow' + const hasOperations = Array.isArray(params.operations) && params.operations.length > 0 + const hideTextForEditWorkflow = isEditWorkflow && hasOperations + return (
-
- -
+ {!hideTextForEditWorkflow && ( +
+ +
+ )} {isExpandableTool && expanded &&
{renderPendingDetails()}
} {showRemoveAutoAllow && isAutoAllowed && (
diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 581fe0511f..f45cd78660 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -99,6 +99,7 @@ export interface SendMessageRequest { workflowId?: string executionId?: string }> + commands?: string[] } /** diff --git a/apps/sim/lib/copilot/tools/client/other/crawl-website.ts b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts new file mode 100644 index 0000000000..e6ab7d46a9 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts @@ -0,0 +1,54 @@ +import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class CrawlWebsiteClientTool extends BaseClientTool { + static readonly id = 'crawl_website' + + constructor(toolCallId: string) { + super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Crawled website', icon: Globe }, + [ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.url && typeof params.url === 'string') { + const url = params.url + const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url + + switch (state) { + case ClientToolCallState.success: + return `Crawled ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Crawling ${truncated}` + case ClientToolCallState.error: + return `Failed to crawl ${truncated}` + case ClientToolCallState.aborted: + return `Aborted crawling ${truncated}` + case ClientToolCallState.rejected: + return `Skipped crawling ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} + diff --git a/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts new file mode 100644 index 0000000000..50c811a8e1 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts @@ -0,0 +1,55 @@ +import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class GetPageContentsClientTool extends BaseClientTool { + static readonly id = 'get_page_contents' + + constructor(toolCallId: string) { + super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText }, + [ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) { + const firstUrl = String(params.urls[0]) + const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl + const count = params.urls.length + + switch (state) { + case ClientToolCallState.success: + return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}` + case ClientToolCallState.error: + return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}` + case ClientToolCallState.aborted: + return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}` + case ClientToolCallState.rejected: + return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} + diff --git a/apps/sim/lib/copilot/tools/client/other/scrape-page.ts b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts new file mode 100644 index 0000000000..f093d061d7 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts @@ -0,0 +1,54 @@ +import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class ScrapePageClientTool extends BaseClientTool { + static readonly id = 'scrape_page' + + constructor(toolCallId: string) { + super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Scraped page', icon: Globe }, + [ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.url && typeof params.url === 'string') { + const url = params.url + const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url + + switch (state) { + case ClientToolCallState.success: + return `Scraped ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Scraping ${truncated}` + case ClientToolCallState.error: + return `Failed to scrape ${truncated}` + case ClientToolCallState.aborted: + return `Aborted scraping ${truncated}` + case ClientToolCallState.rejected: + return `Skipped scraping ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} + diff --git a/apps/sim/lib/copilot/tools/client/other/search-online.ts b/apps/sim/lib/copilot/tools/client/other/search-online.ts index f5022c3f44..fd96c5cc99 100644 --- a/apps/sim/lib/copilot/tools/client/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/client/other/search-online.ts @@ -1,19 +1,9 @@ -import { createLogger } from '@sim/logger' import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' - -interface SearchOnlineArgs { - query: string - num?: number - type?: string - gl?: string - hl?: string -} export class SearchOnlineClientTool extends BaseClientTool { static readonly id = 'search_online' @@ -32,6 +22,7 @@ export class SearchOnlineClientTool extends BaseClientTool { [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, }, + interrupt: undefined, getDynamicText: (params, state) => { if (params?.query && typeof params.query === 'string') { const query = params.query @@ -56,28 +47,7 @@ export class SearchOnlineClientTool extends BaseClientTool { }, } - async execute(args?: SearchOnlineArgs): Promise { - const logger = createLogger('SearchOnlineClientTool') - try { - this.setState(ClientToolCallState.executing) - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'search_online', payload: args || {} }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Online search complete', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Search failed') - } + async execute(): Promise { + return } } diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 97b785177a..88fdfde8d7 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -43,6 +43,9 @@ import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs' +import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website' +import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents' +import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page' import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online' import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns' import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep' @@ -120,6 +123,9 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { search_library_docs: (id) => new SearchLibraryDocsClientTool(id), search_patterns: (id) => new SearchPatternsClientTool(id), search_errors: (id) => new SearchErrorsClientTool(id), + scrape_page: (id) => new ScrapePageClientTool(id), + get_page_contents: (id) => new GetPageContentsClientTool(id), + crawl_website: (id) => new CrawlWebsiteClientTool(id), remember_debug: (id) => new RememberDebugClientTool(id), set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id), get_credentials: (id) => new GetCredentialsClientTool(id), @@ -179,6 +185,9 @@ export const CLASS_TOOL_METADATA: Record()( // Call copilot API const apiMode: 'ask' | 'agent' | 'plan' = mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent' + + // Extract slash commands from contexts (lowercase) and filter them out from contexts + const commands = contexts + ?.filter((c) => c.kind === 'slash_command' && 'command' in c) + .map((c) => (c as any).command.toLowerCase()) as string[] | undefined + const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command') + const result = await sendStreamingMessage({ message: messageToSend, userMessageId: userMessage.id, @@ -2526,7 +2542,8 @@ export const useCopilotStore = create()( createNewChat: !currentChat, stream, fileAttachments, - contexts, + contexts: filteredContexts, + commands: commands?.length ? commands : undefined, abortSignal: abortController.signal, }) From 23f4305bc0821a7b508a58d555688065eb8a57af Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 12 Jan 2026 20:34:07 -0800 Subject: [PATCH 03/16] Web --- .../components/slash-menu/slash-menu.tsx | 122 ++++++++++++++---- .../components/user-input/user-input.tsx | 111 ++++++++++++---- 2 files changed, 187 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx index 97b339ea5d..dcfe824057 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -4,24 +4,41 @@ import { useMemo } from 'react' import { Popover, PopoverAnchor, + PopoverBackButton, PopoverContent, + PopoverFolder, PopoverItem, PopoverScrollArea, } from '@/components/emcn' import type { useMentionMenu } from '../../hooks/use-mention-menu' /** - * Slash command options + * Top-level slash command options */ -const SLASH_COMMANDS = [ +const TOP_LEVEL_COMMANDS = [ { id: 'plan', label: 'plan' }, { id: 'debug', label: 'debug' }, { id: 'fast', label: 'fast' }, { id: 'superagent', label: 'superagent' }, - { id: 'research', label: 'research' }, { id: 'deploy', label: 'deploy' }, ] as const +/** + * Web submenu commands + */ +const WEB_COMMANDS = [ + { id: 'search', label: 'search' }, + { id: 'research', label: 'research' }, + { id: 'crawl', label: 'crawl' }, + { id: 'read', label: 'read' }, + { id: 'scrape', label: 'scrape' }, +] as const + +/** + * All command labels for filtering + */ +const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + interface SlashMenuProps { mentionMenu: ReturnType message: string @@ -36,8 +53,16 @@ interface SlashMenuProps { * @returns Rendered slash menu */ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) { - const { mentionMenuRef, menuListRef, getActiveSlashQueryAtPosition, getCaretPos, submenuActiveIndex } = - mentionMenu + const { + mentionMenuRef, + menuListRef, + getActiveSlashQueryAtPosition, + getCaretPos, + submenuActiveIndex, + mentionActiveIndex, + openSubmenuFor, + setOpenSubmenuFor, + } = mentionMenu /** * Get the current query string after / @@ -49,13 +74,16 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr }, [message, getCaretPos, getActiveSlashQueryAtPosition]) /** - * Filter commands based on query + * Filter commands based on query (search across all commands when there's a query) */ const filteredCommands = useMemo(() => { - if (!currentQuery) return SLASH_COMMANDS - return SLASH_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery)) + if (!currentQuery) return null // Show folder view when no query + return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery)) }, [currentQuery]) + // Show aggregated view when there's a query + const showAggregatedView = currentQuery.length > 0 + // Compute caret viewport position via mirror technique for precise anchoring const textareaEl = mentionMenu.textareaRef.current if (!textareaEl) return null @@ -112,6 +140,9 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr const spaceBelow = window.innerHeight - caretViewport.top - margin const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top' + // Check if we're in folder navigation mode (no query, not in submenu) + const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView + return ( e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > + - {filteredCommands.length === 0 ? ( -
- No commands found -
+ {openSubmenuFor === 'Web' ? ( + // Web submenu view + <> + {WEB_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + ))} + + ) : showAggregatedView ? ( + // Aggregated filtered view + <> + {filteredCommands && filteredCommands.length === 0 ? ( +
+ No commands found +
+ ) : ( + filteredCommands?.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + )) + )} + ) : ( - filteredCommands.map((cmd, index) => ( - onSelectCommand(cmd.label)} - data-idx={index} - active={index === submenuActiveIndex} + // Folder navigation view + <> + {TOP_LEVEL_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={isInFolderNavigationMode && index === mentionActiveIndex} + > + {cmd.label} + + ))} + + setOpenSubmenuFor('Web')} + active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length} + data-idx={TOP_LEVEL_COMMANDS.length} > - {cmd.label} - - )) + {WEB_COMMANDS.map((cmd) => ( + onSelectCommand(cmd.label)}> + {cmd.label} + + ))} + + )}
) } - 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 362b099068..0b4c5de6ae 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 @@ -410,28 +410,71 @@ const UserInput = forwardRef( // Arrow navigation in slash menu if (showSlashMenu) { + const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy', 'search'] + const WEB_COMMANDS = ['crawl', 'read', 'scrape'] + const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + + const caretPos = mentionMenu.getCaretPos() + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) + const query = activeSlash?.query.trim().toLowerCase() || '' + const showAggregatedView = query.length > 0 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() - const SLASH_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy'] - const caretPos = mentionMenu.getCaretPos() - const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) - const query = activeSlash?.query.trim().toLowerCase() || '' - const filtered = query - ? SLASH_COMMANDS.filter((cmd) => cmd.includes(query)) - : SLASH_COMMANDS - const last = Math.max(0, filtered.length - 1) - mentionMenu.setSubmenuActiveIndex((prev) => { - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) - return next - }) + + if (mentionMenu.openSubmenuFor === 'Web') { + // Navigate in Web submenu + const last = WEB_COMMANDS.length - 1 + mentionMenu.setSubmenuActiveIndex((prev) => { + const next = + e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } else if (showAggregatedView) { + // Navigate in filtered view + const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + const last = Math.max(0, filtered.length - 1) + mentionMenu.setSubmenuActiveIndex((prev) => { + if (filtered.length === 0) return 0 + const next = + e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } else { + // Navigate in folder view (top-level + Web folder) + const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder + const last = totalItems - 1 + mentionMenu.setMentionActiveIndex((prev) => { + const next = + e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } return } - // Prevent ArrowLeft/Right from moving cursor when slash menu is open - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + + // Arrow right to enter Web submenu + if (e.key === 'ArrowRight') { e.preventDefault() + if (!showAggregatedView && !mentionMenu.openSubmenuFor) { + // Check if Web folder is selected + if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) { + mentionMenu.setOpenSubmenuFor('Web') + mentionMenu.setSubmenuActiveIndex(0) + } + } + return + } + + // Arrow left to exit submenu + if (e.key === 'ArrowLeft') { + e.preventDefault() + if (mentionMenu.openSubmenuFor) { + mentionMenu.setOpenSubmenuFor(null) + } return } } @@ -445,17 +488,37 @@ const UserInput = forwardRef( if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() if (showSlashMenu) { - // Handle slash menu selection - const SLASH_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy'] + const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy'] + const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape'] + const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + const caretPos = mentionMenu.getCaretPos() const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) const query = activeSlash?.query.trim().toLowerCase() || '' - const filtered = query - ? SLASH_COMMANDS.filter((cmd) => cmd.includes(query)) - : SLASH_COMMANDS - if (filtered.length > 0) { - const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0] + const showAggregatedView = query.length > 0 + + if (mentionMenu.openSubmenuFor === 'Web') { + // Select from Web submenu + const selectedCommand = WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0] handleSlashCommandSelect(selectedCommand) + } else if (showAggregatedView) { + // Select from filtered view + const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + if (filtered.length > 0) { + const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0] + handleSlashCommandSelect(selectedCommand) + } + } else { + // Folder navigation view + const selectedIndex = mentionMenu.mentionActiveIndex + if (selectedIndex < TOP_LEVEL_COMMANDS.length) { + // Top-level command selected + handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex]) + } else if (selectedIndex === TOP_LEVEL_COMMANDS.length) { + // Web folder selected - open it + mentionMenu.setOpenSubmenuFor('Web') + mentionMenu.setSubmenuActiveIndex(0) + } } return } From 4ee863a9ce19bffc7744b94593b32a24d65377dc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 12 Jan 2026 20:37:23 -0800 Subject: [PATCH 04/16] Fix popover --- .../components/copilot/components/user-input/user-input.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0b4c5de6ae..41a7cdcd5f 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 @@ -410,8 +410,8 @@ const UserInput = forwardRef( // Arrow navigation in slash menu if (showSlashMenu) { - const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'research', 'deploy', 'search'] - const WEB_COMMANDS = ['crawl', 'read', 'scrape'] + const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy'] + const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape'] const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] const caretPos = mentionMenu.getCaretPos() @@ -460,7 +460,7 @@ const UserInput = forwardRef( if (e.key === 'ArrowRight') { e.preventDefault() if (!showAggregatedView && !mentionMenu.openSubmenuFor) { - // Check if Web folder is selected + // Check if Web folder is selected (it's after all top-level commands) if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) { mentionMenu.setOpenSubmenuFor('Web') mentionMenu.setSubmenuActiveIndex(0) From acb696207da15bca0f00587913fe7cc74b07a25e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 10:09:58 -0800 Subject: [PATCH 05/16] Fix commands ui --- .../copilot-message/copilot-message.tsx | 1 + .../components/tool-call/tool-call.tsx | 8 ++- .../components/slash-menu/slash-menu.tsx | 4 +- .../hooks/use-context-management.ts | 23 ++++++-- .../user-input/hooks/use-mention-menu.ts | 36 ++++++++---- .../user-input/hooks/use-mention-tokens.ts | 6 ++ .../components/user-input/user-input.tsx | 13 +++-- .../copilot/tools/client/init-tool-configs.ts | 1 + .../copilot/tools/client/other/superagent.ts | 57 +++++++++++++++++++ 9 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/other/superagent.ts 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 3f7a6ef4cc..fbbfc537f2 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 @@ -279,6 +279,7 @@ const CopilotMessage: FC = memo( onModeChange={setMode} panelWidth={panelWidth} clearOnSubmit={false} + initialContexts={message.contexts} /> {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index cf2b9de231..6f9893c410 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -497,6 +497,11 @@ const ACTION_VERBS = [ 'Accessed', 'Managing', 'Managed', + 'Scraping', + 'Scraped', + 'Crawling', + 'Crawled', + 'Getting', ] as const /** @@ -1160,7 +1165,7 @@ function SubAgentThinkingContent({ * Default behavior is to NOT collapse (stay expanded like edit). * Only these specific subagents collapse into "Planned for Xs >" style headers. */ -const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info']) +const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info', 'superagent']) /** * SubagentContentRenderer handles the rendering of subagent content. @@ -1968,6 +1973,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: 'tour', 'info', 'workflow', + 'superagent' ] const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx index dcfe824057..bc4693a5f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -21,6 +21,7 @@ const TOP_LEVEL_COMMANDS = [ { id: 'fast', label: 'fast' }, { id: 'superagent', label: 'superagent' }, { id: 'deploy', label: 'deploy' }, + { id: 'research', label: 'research' }, ] as const /** @@ -28,10 +29,9 @@ const TOP_LEVEL_COMMANDS = [ */ const WEB_COMMANDS = [ { id: 'search', label: 'search' }, - { id: 'research', label: 'research' }, - { id: 'crawl', label: 'crawl' }, { id: 'read', label: 'read' }, { id: 'scrape', label: 'scrape' }, + { id: 'crawl', label: 'crawl' }, ] as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 6ec97ddbfa..9e85bbeca6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { ChatContext } from '@/stores/panel' interface UseContextManagementProps { /** Current message text */ message: string + /** Initial contexts to populate when editing a message */ + initialContexts?: ChatContext[] } /** @@ -13,8 +15,17 @@ interface UseContextManagementProps { * @param props - Configuration object * @returns Context state and management functions */ -export function useContextManagement({ message }: UseContextManagementProps) { - const [selectedContexts, setSelectedContexts] = useState([]) +export function useContextManagement({ message, initialContexts }: UseContextManagementProps) { + const [selectedContexts, setSelectedContexts] = useState(initialContexts ?? []) + const initializedRef = useRef(false) + + // Initialize with initial contexts when they're first provided (for edit mode) + useEffect(() => { + if (initialContexts && initialContexts.length > 0 && !initializedRef.current) { + setSelectedContexts(initialContexts) + initializedRef.current = true + } + }, [initialContexts]) /** * Adds a context to the selected contexts list, avoiding duplicates @@ -140,8 +151,10 @@ export function useContextManagement({ message }: UseContextManagementProps) { // Check for slash command tokens or mention tokens based on kind const isSlashCommand = c.kind === 'slash_command' const prefix = isSlashCommand ? '/' : '@' - const token = ` ${prefix}${c.label} ` - return message.includes(token) + const tokenWithSpaces = ` ${prefix}${c.label} ` + const tokenAtStart = `${prefix}${c.label} ` + // Token can appear with leading space OR at the start of the message + return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart) }) return filtered.length === prev.length ? prev : filtered }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index e069a2ce91..ecba4cd45c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -130,11 +130,25 @@ export function useMentionMenu({ // Ensure '/' starts a token (start or whitespace before) if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null - // Check if this '/' is part of a completed slash token ( /command ) + // Check if this '/' is part of a completed slash token if (selectedContexts.length > 0) { - const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[] - for (const label of labels) { - // Space-wrapped token: " /label " + // Only check slash_command contexts + const slashLabels = selectedContexts + .filter((c) => c.kind === 'slash_command') + .map((c) => c.label) + .filter(Boolean) as string[] + + for (const label of slashLabels) { + // Check for token at start of text: "/label " + if (slashIndex === 0) { + const startToken = `/${label} ` + if (text.startsWith(startToken)) { + // This slash is part of a completed token + return null + } + } + + // Check for space-wrapped token: " /label " const token = ` /${label} ` let fromIndex = 0 while (fromIndex <= text.length) { @@ -256,9 +270,10 @@ export function useMentionMenu({ const before = message.slice(0, active.start) const after = message.slice(active.end) - // Always include leading space, avoid duplicate if one exists - const needsLeadingSpace = !before.endsWith(' ') - const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} ` + // Add leading space only if not at start and previous char isn't whitespace + const needsLeadingSpace = before.length > 0 && !before.endsWith(' ') + // Always add trailing space for easy continued typing + const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} ` const next = `${before}${insertion}${after}` onMessageChange(next) @@ -290,9 +305,10 @@ export function useMentionMenu({ const before = message.slice(0, active.start) const after = message.slice(active.end) - // Always include leading space, avoid duplicate if one exists - const needsLeadingSpace = !before.endsWith(' ') - const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} ` + // Add leading space only if not at start and previous char isn't whitespace + const needsLeadingSpace = before.length > 0 && !before.endsWith(' ') + // Always add trailing space for easy continued typing + const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} ` const next = `${before}${insertion}${after}` onMessageChange(next) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index 2fbc5cf888..8d21fe83d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -60,6 +60,12 @@ export function useMentionTokens({ const isSlashCommand = matchingContext?.kind === 'slash_command' const prefix = isSlashCommand ? '/' : '@' + // Check for token at the very start of the message (no leading space) + const tokenAtStart = `${prefix}${label} ` + if (message.startsWith(tokenAtStart)) { + ranges.push({ start: 0, end: tokenAtStart.length, label }) + } + // Space-wrapped token: " @label " or " /label " (search from start) const token = ` ${prefix}${label} ` let fromIndex = 0 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 41a7cdcd5f..41038414f9 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 @@ -68,6 +68,8 @@ interface UserInputProps { hideModeSelector?: boolean /** Disable @mention functionality */ disableMentions?: boolean + /** Initial contexts for editing a message with existing context mentions */ + initialContexts?: ChatContext[] } interface UserInputRef { @@ -104,6 +106,7 @@ const UserInput = forwardRef( onModelChangeOverride, hideModeSelector = false, disableMentions = false, + initialContexts, }, ref ) => { @@ -142,7 +145,7 @@ const UserInput = forwardRef( // Custom hooks - order matters for ref sharing // Context management (manages selectedContexts state) - const contextManagement = useContextManagement({ message }) + const contextManagement = useContextManagement({ message, initialContexts }) // Mention menu const mentionMenu = useMentionMenu({ @@ -410,8 +413,8 @@ const UserInput = forwardRef( // Arrow navigation in slash menu if (showSlashMenu) { - const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy'] - const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape'] + const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy', 'research'] + const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] const caretPos = mentionMenu.getCaretPos() @@ -488,8 +491,8 @@ const UserInput = forwardRef( if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() if (showSlashMenu) { - const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy'] - const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape'] + const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy', 'research'] + const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] const caretPos = mentionMenu.getCaretPos() diff --git a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts index 821e5ec8d6..b2d480f037 100644 --- a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts +++ b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts @@ -18,6 +18,7 @@ import './other/make-api-request' import './other/plan' import './other/research' import './other/sleep' +import './other/superagent' import './other/test' import './other/tour' import './other/workflow' diff --git a/apps/sim/lib/copilot/tools/client/other/superagent.ts b/apps/sim/lib/copilot/tools/client/other/superagent.ts new file mode 100644 index 0000000000..d75fe16c48 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/superagent.ts @@ -0,0 +1,57 @@ +import { Loader2, Sparkles, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' +import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' + +interface SuperagentArgs { + instruction: string +} + +/** + * Superagent tool that spawns a powerful subagent for complex tasks. + * This tool auto-executes and the actual work is done by the superagent. + * The subagent's output is streamed as nested content under this tool call. + */ +export class SuperagentClientTool extends BaseClientTool { + static readonly id = 'superagent' + + constructor(toolCallId: string) { + super(toolCallId, SuperagentClientTool.id, SuperagentClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles }, + [ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle }, + }, + uiConfig: { + subagent: { + streamingLabel: 'Superagent working', + completedLabel: 'Superagent completed', + shouldCollapse: true, + outputArtifacts: [], + }, + }, + } + + /** + * Execute the superagent tool. + * This just marks the tool as executing - the actual work is done server-side + * by the superagent, and its output is streamed as subagent events. + */ + async execute(_args?: SuperagentArgs): Promise { + this.setState(ClientToolCallState.executing) + } +} + +// Register UI config at module load +registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!) + From 2c250edb6a67ecc92c7f6627ea9dcf9e55beab4c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 10:12:53 -0800 Subject: [PATCH 06/16] Fix for context mentions too --- .../user-input/hooks/use-mention-menu.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index ecba4cd45c..8a07146e05 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -70,11 +70,25 @@ export function useMentionMenu({ // Ensure '@' starts a token (start or whitespace before) if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null - // Check if this '@' is part of a completed mention token ( @label ) + // Check if this '@' is part of a completed mention token if (selectedContexts.length > 0) { - const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[] - for (const label of labels) { - // Space-wrapped token: " @label " + // Only check non-slash_command contexts for mentions + const mentionLabels = selectedContexts + .filter((c) => c.kind !== 'slash_command') + .map((c) => c.label) + .filter(Boolean) as string[] + + for (const label of mentionLabels) { + // Check for token at start of text: "@label " + if (atIndex === 0) { + const startToken = `@${label} ` + if (text.startsWith(startToken)) { + // This @ is part of a completed token + return null + } + } + + // Check for space-wrapped token: " @label " const token = ` @${label} ` let fromIndex = 0 while (fromIndex <= text.length) { @@ -88,7 +102,6 @@ export function useMentionMenu({ // Check if the @ we found is the @ of this completed token if (atIndex === atPositionInToken) { // The @ we found is part of a completed mention - // Don't show menu - user is typing after the completed mention return null } From f2a9bf49d556a2f98de9e0ef53e454e68026f2fc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 10:32:15 -0800 Subject: [PATCH 07/16] Improvem tool names for options and config --- .../user-input/components/slash-menu/slash-menu.tsx | 6 +++--- .../components/copilot/components/user-input/user-input.tsx | 2 +- .../sim/lib/copilot/tools/client/blocks/get-block-config.ts | 5 ++++- .../lib/copilot/tools/client/blocks/get-block-options.ts | 5 ++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx index bc4693a5f4..15bb2a7d04 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -16,12 +16,12 @@ import type { useMentionMenu } from '../../hooks/use-mention-menu' * Top-level slash command options */ const TOP_LEVEL_COMMANDS = [ + { id: 'fast', label: 'fast' }, { id: 'plan', label: 'plan' }, { id: 'debug', label: 'debug' }, - { id: 'fast', label: 'fast' }, - { id: 'superagent', label: 'superagent' }, - { id: 'deploy', label: 'deploy' }, { id: 'research', label: 'research' }, + { id: 'deploy', label: 'deploy' }, + { id: 'superagent', label: 'superagent' }, ] as const /** 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 41038414f9..1be5441b2d 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 @@ -413,7 +413,7 @@ const UserInput = forwardRef( // Arrow navigation in slash menu if (showSlashMenu) { - const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy', 'research'] + const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent'] const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts index 6b3a15c531..443450aaed 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { getBlock } from '@/blocks/registry' import { BaseClientTool, type BaseClientToolMetadata, @@ -39,7 +40,9 @@ export class GetBlockConfigClientTool extends BaseClientTool { }, getDynamicText: (params, state) => { if (params?.blockType && typeof params.blockType === 'string') { - const blockName = params.blockType.replace(/_/g, ' ') + // Look up the block config to get the human-readable name + const blockConfig = getBlock(params.blockType) + const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase() const opSuffix = params.operation ? ` (${params.operation})` : '' switch (state) { diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index 41cd7bd8f6..b097eef568 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { getBlock } from '@/blocks/registry' import { BaseClientTool, type BaseClientToolMetadata, @@ -37,7 +38,9 @@ export class GetBlockOptionsClientTool extends BaseClientTool { }, getDynamicText: (params, state) => { if (params?.blockId && typeof params.blockId === 'string') { - const blockName = params.blockId.replace(/_/g, ' ') + // Look up the block config to get the human-readable name + const blockConfig = getBlock(params.blockId) + const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase() switch (state) { case ClientToolCallState.success: From 87a7162212e7eba0dfc7eeb46c2dc9ec2791467e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 10:46:49 -0800 Subject: [PATCH 08/16] Fix thinking text scroll --- .../components/thinking-block.tsx | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) 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 54c7042e75..9ded75dba2 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 @@ -133,7 +133,8 @@ export function ThinkingBlock({ return () => container.removeEventListener('scroll', handleScroll) }, [isExpanded, userHasScrolledAway]) - // Smart auto-scroll: only scroll if user hasn't scrolled away + // Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away + // This matches the main chat behavior in useScrollManagement useEffect(() => { if (!isStreaming || !isExpanded || userHasScrolledAway) return @@ -141,20 +142,16 @@ export function ThinkingBlock({ const container = scrollContainerRef.current if (!container) return - const { scrollTop, scrollHeight, clientHeight } = container - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const isNearBottom = distanceFromBottom <= 50 - - if (isNearBottom) { - programmaticScrollRef.current = true - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) - window.setTimeout(() => { - programmaticScrollRef.current = false - }, 150) - } + // Always scroll to bottom during streaming (like main chat does) + // User can break out by scrolling up, which sets userHasScrolledAway + programmaticScrollRef.current = true + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + window.setTimeout(() => { + programmaticScrollRef.current = false + }, 150) }, SCROLL_INTERVAL) return () => window.clearInterval(intervalId) From 3b925c807f4bd574deeba410e80303b790e26c1f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 10:50:11 -0800 Subject: [PATCH 09/16] Fix ishosted --- apps/sim/lib/core/config/feature-flags.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 33317ba1cf..5e7f26d0d0 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,7 +1,7 @@ /** * Environment utility functions for consistent environment detection across the application */ -import { env, isFalsy, isTruthy } from './env' +import { env, getEnv, isFalsy, isTruthy } from './env' /** * Is the application running in production mode @@ -21,7 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = true +export const isHosted = + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled From 936d2bd729eded3813d74ade766d9d4d2760d07b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 11:23:37 -0800 Subject: [PATCH 10/16] Ui --- .../components/smooth-streaming.tsx | 150 ++++++++++++----- .../copilot-message/copilot-message.tsx | 40 +++-- .../components/tool-call/tool-call.tsx | 4 +- .../hooks/use-scroll-management.ts | 157 ++++++++---------- 4 files changed, 211 insertions(+), 140 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx index 71de980ce9..c4d49dd008 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx @@ -2,18 +2,38 @@ import { memo, useEffect, useRef, useState } from 'react' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' /** - * Character animation delay in milliseconds + * Minimum delay between characters (fast catch-up mode) */ -const CHARACTER_DELAY = 3 +const MIN_DELAY = 1 + +/** + * Maximum delay between characters (when waiting for content) + */ +const MAX_DELAY = 12 + +/** + * Default delay when streaming normally + */ +const DEFAULT_DELAY = 4 + +/** + * How far behind (in characters) before we speed up + */ +const CATCH_UP_THRESHOLD = 20 + +/** + * How close to content before we slow down + */ +const SLOW_DOWN_THRESHOLD = 5 /** * StreamingIndicator shows animated dots during message streaming - * Uses CSS classes for animations to follow best practices + * Used as a standalone indicator when no content has arrived yet * * @returns Animated loading indicator */ export const StreamingIndicator = memo(() => ( -
+
@@ -24,6 +44,22 @@ export const StreamingIndicator = memo(() => ( StreamingIndicator.displayName = 'StreamingIndicator' +/** + * InlineStreamingDots shows small animated dots inline with text + * Used at the end of streaming content to indicate more is coming + */ +const InlineStreamingDots = memo(() => ( + + + + + + + +)) + +InlineStreamingDots.displayName = 'InlineStreamingDots' + /** * Props for the SmoothStreamingText component */ @@ -32,96 +68,132 @@ interface SmoothStreamingTextProps { content: string /** Whether the content is actively streaming */ isStreaming: boolean + /** Whether to show inline streaming dots at the end of content. Defaults to true. */ + showIndicator?: boolean +} + +/** + * Calculates adaptive delay based on how far behind animation is from actual content + * + * @param displayedLength - Current displayed content length + * @param totalLength - Total available content length + * @returns Delay in milliseconds + */ +function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number { + const charsRemaining = totalLength - displayedLength + + if (charsRemaining > CATCH_UP_THRESHOLD) { + // Far behind - speed up to catch up + // Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind + const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50) + return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor) + } + + if (charsRemaining <= SLOW_DOWN_THRESHOLD) { + // Close to content edge - slow down to feel natural + // The closer we are, the slower we go (up to MAX_DELAY) + const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD + return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor + } + + // Normal streaming speed + return DEFAULT_DELAY } /** * SmoothStreamingText component displays text with character-by-character animation - * Creates a smooth streaming effect for AI responses + * Creates a smooth streaming effect for AI responses with adaptive speed + * + * Uses adaptive pacing: speeds up when catching up, slows down near content edge * * @param props - Component props * @returns Streaming text with smooth animation */ export const SmoothStreamingText = memo( - ({ content, isStreaming }: SmoothStreamingTextProps) => { + ({ content, isStreaming, showIndicator = true }: SmoothStreamingTextProps) => { const [displayedContent, setDisplayedContent] = useState('') const contentRef = useRef(content) - const timeoutRef = useRef(null) + const rafRef = useRef(null) const indexRef = useRef(0) - const streamingStartTimeRef = useRef(null) + const lastFrameTimeRef = useRef(0) const isAnimatingRef = useRef(false) - /** - * Handles content streaming animation - * Updates displayed content character by character during streaming - */ useEffect(() => { contentRef.current = content if (content.length === 0) { setDisplayedContent('') indexRef.current = 0 - streamingStartTimeRef.current = null return } if (isStreaming) { - if (streamingStartTimeRef.current === null) { - streamingStartTimeRef.current = Date.now() - } + if (indexRef.current < content.length && !isAnimatingRef.current) { + isAnimatingRef.current = true + lastFrameTimeRef.current = performance.now() - if (indexRef.current < content.length) { - const animateText = () => { + const animateText = (timestamp: number) => { const currentContent = contentRef.current const currentIndex = indexRef.current + const elapsed = timestamp - lastFrameTimeRef.current + + // Calculate adaptive delay based on how far behind we are + const delay = calculateAdaptiveDelay(currentIndex, currentContent.length) + + if (elapsed >= delay) { + if (currentIndex < currentContent.length) { + const newDisplayed = currentContent.slice(0, currentIndex + 1) + setDisplayedContent(newDisplayed) + indexRef.current = currentIndex + 1 + lastFrameTimeRef.current = timestamp + } + } - if (currentIndex < currentContent.length) { - const chunkSize = 1 - const newDisplayed = currentContent.slice(0, currentIndex + chunkSize) - - setDisplayedContent(newDisplayed) - indexRef.current = currentIndex + chunkSize - - timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY) + if (indexRef.current < currentContent.length) { + rafRef.current = requestAnimationFrame(animateText) } else { isAnimatingRef.current = false } } - if (!isAnimatingRef.current) { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - - isAnimatingRef.current = true - animateText() - } + rafRef.current = requestAnimationFrame(animateText) + } else if (indexRef.current < content.length && isAnimatingRef.current) { + // Animation already running, it will pick up new content automatically } } else { + // Streaming ended - show full content immediately + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } setDisplayedContent(content) indexRef.current = content.length isAnimatingRef.current = false - streamingStartTimeRef.current = null } return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) } isAnimatingRef.current = false } }, [content, isStreaming]) + // Show inline dots when streaming and we have some content displayed (if enabled) + const showInlineDots = showIndicator && isStreaming && displayedContent.length > 0 + return (
+ {showInlineDots && }
) }, (prevProps, nextProps) => { // Prevent re-renders during streaming unless content actually changed return ( - prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming - // markdownComponents is now memoized so no need to compare + prevProps.content === nextProps.content && + prevProps.isStreaming === nextProps.isStreaming && + prevProps.showIndicator === nextProps.showIndicator ) } ) 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 fbbfc537f2..01bd6b291b 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 @@ -187,6 +187,7 @@ const CopilotMessage: FC = memo( ) // Memoize content blocks to avoid re-rendering unchanged blocks + // No entrance animations to prevent layout shift const memoizedContentBlocks = useMemo(() => { if (!message.contentBlocks || message.contentBlocks.length === 0) { return null @@ -205,14 +206,10 @@ const CopilotMessage: FC = memo( // Use smooth streaming for the last text block if we're streaming const shouldUseSmoothing = isStreaming && isLastTextBlock + const blockKey = `text-${index}-${block.timestamp || index}` return ( -
0 ? 'opacity-100' : 'opacity-70' - } ${shouldUseSmoothing ? 'translate-y-0 transition-transform duration-100 ease-out' : ''}`} - > +
{shouldUseSmoothing ? ( ) : ( @@ -224,8 +221,10 @@ const CopilotMessage: FC = memo( if (block.type === 'thinking') { // Check if there are any blocks after this one (tool calls, text, etc.) const hasFollowingContent = index < message.contentBlocks!.length - 1 + const blockKey = `thinking-${index}-${block.timestamp || index}` + return ( -
+
= memo( ) } if (block.type === 'tool_call') { + const blockKey = `tool-${block.toolCall.id}` + return ( -
+
) @@ -465,18 +463,30 @@ const CopilotMessage: FC = memo( ) } + // Check if there's any visible content in the blocks + const hasVisibleContent = useMemo(() => { + if (!message.contentBlocks || message.contentBlocks.length === 0) return false + return message.contentBlocks.some((block) => { + if (block.type === 'text') { + const parsed = parseSpecialTags(block.content) + return parsed.cleanContent.trim().length > 0 + } + return block.type === 'thinking' || block.type === 'tool_call' + }) + }, [message.contentBlocks]) + if (isAssistant) { return (
-
+
{/* Content blocks in chronological order */} {memoizedContentBlocks} - {/* Always show streaming indicator at the end while streaming */} - {isStreaming && } + {/* Only show streaming indicator when no content has arrived yet */} + {isStreaming && !hasVisibleContent && } {message.errorType === 'usage_limit' && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 6f9893c410..3a544eae5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -221,7 +221,7 @@ function PlanSteps({
{streaming && isLastStep ? ( - + ) : ( )} @@ -363,7 +363,7 @@ export function OptionsSelector({ )} > {streaming ? ( - + ) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts index 5959368e3a..658036555e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts @@ -39,42 +39,49 @@ export function useScrollManagement( const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false) const programmaticScrollInProgressRef = useRef(false) const lastScrollTopRef = useRef(0) + const lastScrollHeightRef = useRef(0) + const rafIdRef = useRef(null) const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth' const stickinessThreshold = options?.stickinessThreshold ?? 100 - /** - * Scrolls the container to the bottom with smooth animation - */ const getScrollContainer = useCallback((): HTMLElement | null => { - // Prefer the element with the ref (our scrollable div) if (scrollAreaRef.current) return scrollAreaRef.current return null }, []) - const scrollToBottom = useCallback(() => { - const scrollContainer = getScrollContainer() - if (!scrollContainer) return + /** + * Scrolls the container to the bottom + * Uses 'auto' for streaming to prevent jitter, 'smooth' for user actions + */ + const scrollToBottom = useCallback( + (forceInstant = false) => { + const scrollContainer = getScrollContainer() + if (!scrollContainer) return - programmaticScrollInProgressRef.current = true - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: scrollBehavior, - }) - // Best-effort reset; not all browsers fire scrollend reliably - window.setTimeout(() => { - programmaticScrollInProgressRef.current = false - }, 200) - }, [getScrollContainer, scrollBehavior]) + programmaticScrollInProgressRef.current = true + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: forceInstant ? 'auto' : scrollBehavior, + }) + // Reset flag after scroll completes + window.setTimeout( + () => { + programmaticScrollInProgressRef.current = false + }, + forceInstant ? 16 : 200 + ) + }, + [getScrollContainer, scrollBehavior] + ) /** - * Handles scroll events to track user position and show/hide scroll button + * Handles scroll events to track user position */ const handleScroll = useCallback(() => { const scrollContainer = getScrollContainer() if (!scrollContainer) return if (programmaticScrollInProgressRef.current) { - // Ignore scrolls we initiated return } @@ -86,21 +93,18 @@ export function useScrollManagement( if (isSendingMessage) { const delta = scrollTop - lastScrollTopRef.current - const movedUp = delta < -2 // small hysteresis to avoid noise - const movedDown = delta > 2 + const movedUp = delta < -2 if (movedUp) { - // Any upward movement breaks away from sticky during streaming setUserHasScrolledDuringStream(true) } - // If the user has broken away and scrolls back down to the bottom, re-stick - if (userHasScrolledDuringStream && movedDown && nearBottom) { + // Re-stick if user scrolls back to bottom + if (userHasScrolledDuringStream && nearBottom && delta > 2) { setUserHasScrolledDuringStream(false) } } - // Track last scrollTop for direction detection lastScrollTopRef.current = scrollTop }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold]) @@ -109,95 +113,80 @@ export function useScrollManagement( const scrollContainer = getScrollContainer() if (!scrollContainer) return - const handleUserScroll = () => { - handleScroll() - } - - scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true }) - - if ('onscrollend' in scrollContainer) { - scrollContainer.addEventListener('scrollend', handleScroll, { passive: true }) - } - - // Initialize state - window.setTimeout(handleScroll, 100) - // Initialize last scroll position + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }) lastScrollTopRef.current = scrollContainer.scrollTop + lastScrollHeightRef.current = scrollContainer.scrollHeight return () => { - scrollContainer.removeEventListener('scroll', handleUserScroll) - if ('onscrollend' in scrollContainer) { - scrollContainer.removeEventListener('scrollend', handleScroll) - } + scrollContainer.removeEventListener('scroll', handleScroll) } }, [getScrollContainer, handleScroll]) - // Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming + // Scroll on new user message useEffect(() => { if (messages.length === 0) return - const lastMessage = messages[messages.length - 1] - const isNewUserMessage = lastMessage?.role === 'user' - - const shouldAutoScroll = - isNewUserMessage || - (isSendingMessage && !userHasScrolledDuringStream) || - (!isSendingMessage && isNearBottom) - - if (shouldAutoScroll) { - scrollToBottom() - } - }, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream, scrollToBottom]) - - // Reset user scroll state when streaming starts or when user sends a message - useEffect(() => { const lastMessage = messages[messages.length - 1] if (lastMessage?.role === 'user') { setUserHasScrolledDuringStream(false) - programmaticScrollInProgressRef.current = false - const scrollContainer = getScrollContainer() - if (scrollContainer) { - lastScrollTopRef.current = scrollContainer.scrollTop - } + scrollToBottom() } - }, [messages, getScrollContainer]) + }, [messages, scrollToBottom]) // Reset user scroll state when streaming completes const prevIsSendingRef = useRef(false) useEffect(() => { if (prevIsSendingRef.current && !isSendingMessage) { setUserHasScrolledDuringStream(false) + // Final scroll to ensure we're at bottom + if (isNearBottom) { + scrollToBottom() + } } prevIsSendingRef.current = isSendingMessage - }, [isSendingMessage]) + }, [isSendingMessage, isNearBottom, scrollToBottom]) - // While streaming and not broken away, keep pinned to bottom + // While streaming, use RAF to check for content changes and scroll + // This is more efficient than setInterval and syncs with browser rendering useEffect(() => { - if (!isSendingMessage || userHasScrolledDuringStream) return + if (!isSendingMessage || userHasScrolledDuringStream) { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + return + } - const intervalId = window.setInterval(() => { + const checkAndScroll = () => { const scrollContainer = getScrollContainer() - if (!scrollContainer) return + if (!scrollContainer) { + rafIdRef.current = requestAnimationFrame(checkAndScroll) + return + } - const { scrollTop, scrollHeight, clientHeight } = scrollContainer - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const nearBottom = distanceFromBottom <= stickinessThreshold - if (nearBottom) { - scrollToBottom() + const { scrollHeight } = scrollContainer + // Only scroll if content height actually changed + if (scrollHeight !== lastScrollHeightRef.current) { + lastScrollHeightRef.current = scrollHeight + // Use instant scroll during streaming to prevent jitter + scrollToBottom(true) } - }, 100) - return () => window.clearInterval(intervalId) - }, [ - isSendingMessage, - userHasScrolledDuringStream, - getScrollContainer, - scrollToBottom, - stickinessThreshold, - ]) + rafIdRef.current = requestAnimationFrame(checkAndScroll) + } + + rafIdRef.current = requestAnimationFrame(checkAndScroll) + + return () => { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + } + }, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom]) return { scrollAreaRef, - scrollToBottom, + scrollToBottom: () => scrollToBottom(false), } } From d40e34bbfef427d8ece017466f41781114a22edb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 11:55:56 -0800 Subject: [PATCH 11/16] Ui --- .../components/markdown-renderer.tsx | 566 ++++++++---------- .../components/smooth-streaming.tsx | 32 +- .../components/thinking-block.tsx | 168 +++++- .../copilot-message/copilot-message.tsx | 6 +- .../components/tool-call/tool-call.tsx | 8 +- .../hooks/use-scroll-management.ts | 157 ++--- 6 files changed, 503 insertions(+), 434 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 dcc2dffd06..c64d35680f 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 @@ -1,6 +1,6 @@ 'use client' -import React, { useEffect, useMemo, useState } from 'react' +import React, { memo, useCallback, useState } from 'react' import { Check, Copy } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -28,55 +28,95 @@ const getTextContent = (element: React.ReactNode): string => { return '' } -// Global layout fixes for markdown content inside the copilot panel -if (typeof document !== 'undefined') { - const styleId = 'copilot-markdown-fix' - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = ` - /* Prevent any markdown content from expanding beyond the panel */ - .copilot-markdown-wrapper, - .copilot-markdown-wrapper * { - max-width: 100% !important; - } +/** + * Maps common language aliases to supported viewer languages + */ +const LANGUAGE_MAP: Record = { + js: 'javascript', + javascript: 'javascript', + jsx: 'javascript', + ts: 'javascript', + typescript: 'javascript', + tsx: 'javascript', + json: 'json', + python: 'python', + py: 'python', + code: 'javascript', +} - .copilot-markdown-wrapper p, - .copilot-markdown-wrapper li { - overflow-wrap: anywhere !important; - word-break: break-word !important; - } +/** + * Normalizes a language string to a supported viewer language + */ +function normalizeLanguage(lang: string): 'javascript' | 'json' | 'python' { + const normalized = (lang || '').toLowerCase() + return LANGUAGE_MAP[normalized] || 'javascript' +} - .copilot-markdown-wrapper a { - overflow-wrap: anywhere !important; - word-break: break-all !important; - } +/** + * Props for the CodeBlock component + */ +interface CodeBlockProps { + /** Code content to display */ + code: string + /** Language identifier from markdown */ + language: string +} - .copilot-markdown-wrapper code:not(pre code) { - white-space: normal !important; - overflow-wrap: anywhere !important; - word-break: break-word !important; - } +/** + * CodeBlock component with isolated copy state + * Prevents full markdown re-renders when copy button is clicked + */ +const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(() => { + if (code) { + navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [code]) - /* Reduce top margin for first heading (e.g., right after thinking block) */ - .copilot-markdown-wrapper > h1:first-child, - .copilot-markdown-wrapper > h2:first-child, - .copilot-markdown-wrapper > h3:first-child, - .copilot-markdown-wrapper > h4:first-child { - margin-top: 0.25rem !important; - } - ` - document.head.appendChild(style) - } -} + const viewerLanguage = normalizeLanguage(language) + const displayLanguage = language === 'code' ? viewerLanguage : language + + return ( +
+
+ {displayLanguage} + +
+ +
+ ) +}) /** * Link component with hover preview tooltip - * Displays full URL on hover for better UX - * @param props - Component props with href and children - * @returns Link element with tooltip preview */ -function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { +const LinkWithPreview = memo(function LinkWithPreview({ + href, + children, +}: { + href: string + children: React.ReactNode +}) { return ( @@ -94,7 +134,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea ) -} +}) /** * Props for the CopilotMarkdownRenderer component @@ -105,274 +145,198 @@ interface CopilotMarkdownRendererProps { } /** - * CopilotMarkdownRenderer renders markdown content with custom styling - * Supports GitHub-flavored markdown, code blocks with syntax highlighting, - * tables, links with preview, and more - * - * @param props - Component props - * @returns Rendered markdown content + * Static markdown component definitions - optimized for LLM chat spacing + * Tighter spacing compared to traditional prose for better chat UX */ -export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { - const [copiedCodeBlocks, setCopiedCodeBlocks] = useState>({}) - - useEffect(() => { - const timers: Record = {} - - Object.keys(copiedCodeBlocks).forEach((key) => { - if (copiedCodeBlocks[key]) { - timers[key] = setTimeout(() => { - setCopiedCodeBlocks((prev) => ({ ...prev, [key]: false })) - }, 2000) - } - }) - - return () => { - Object.values(timers).forEach(clearTimeout) +const markdownComponents = { + // Paragraphs - tight spacing, no margin on last + p: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + + // Headings - minimal margins for chat context + h1: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

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

+ {children} +

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

+ {children} +

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

+ {children} +

+ ), + + // Lists - compact spacing + ul: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
+ ), + ol: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
+ ), + li: ({ children }: React.LiHTMLAttributes) => ( +
  • + {children} +
  • + ), + + // Code blocks - handled by CodeBlock component + pre: ({ children }: React.HTMLAttributes) => { + let codeContent: React.ReactNode = children + let language = 'code' + + if ( + React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) && + children.type === 'code' + ) { + const childElement = children as React.ReactElement<{ + className?: string + children?: React.ReactNode + }> + codeContent = childElement.props.children + language = childElement.props.className?.replace('language-', '') || 'code' } - }, [copiedCodeBlocks]) - - const markdownComponents = useMemo( - () => ({ - p: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

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

    - {children} -

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

    - {children} -

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

    - {children} -

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

    - {children} -

    - ), - - ul: ({ children }: React.HTMLAttributes) => ( -
      - {children} -
    - ), - ol: ({ children }: React.HTMLAttributes) => ( -
      - {children} -
    - ), - li: ({ - children, - ordered, - }: React.LiHTMLAttributes & { ordered?: boolean }) => ( -
  • - {children} -
  • - ), - - pre: ({ children }: React.HTMLAttributes) => { - let codeContent: React.ReactNode = children - let language = 'code' - - if ( - React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) && - children.type === 'code' - ) { - const childElement = children as React.ReactElement<{ - className?: string - children?: React.ReactNode - }> - codeContent = childElement.props.children - language = childElement.props.className?.replace('language-', '') || 'code' - } - - let actualCodeText = '' - if (typeof codeContent === 'string') { - actualCodeText = codeContent - } else if (React.isValidElement(codeContent)) { - actualCodeText = getTextContent(codeContent) - } else if (Array.isArray(codeContent)) { - actualCodeText = codeContent - .map((child) => - typeof child === 'string' - ? child - : React.isValidElement(child) - ? getTextContent(child) - : '' - ) - .join('') - } else { - actualCodeText = String(codeContent || '') - } - - const codeText = actualCodeText || 'code' - const codeBlockKey = `${language}-${codeText.substring(0, 30).replace(/\s/g, '-')}-${codeText.length}` - - const showCopySuccess = copiedCodeBlocks[codeBlockKey] || false - - const handleCopy = () => { - const textToCopy = actualCodeText - if (textToCopy) { - navigator.clipboard.writeText(textToCopy) - setCopiedCodeBlocks((prev) => ({ ...prev, [codeBlockKey]: true })) - } - } - - const normalizedLanguage = (language || '').toLowerCase() - const viewerLanguage: 'javascript' | 'json' | 'python' = - normalizedLanguage === 'json' - ? 'json' - : normalizedLanguage === 'python' || normalizedLanguage === 'py' - ? 'python' - : 'javascript' - - return ( -
    -
    - - {language === 'code' ? viewerLanguage : language} - - -
    - -
    + let actualCodeText = '' + if (typeof codeContent === 'string') { + actualCodeText = codeContent + } else if (React.isValidElement(codeContent)) { + actualCodeText = getTextContent(codeContent) + } else if (Array.isArray(codeContent)) { + actualCodeText = codeContent + .map((child) => + typeof child === 'string' + ? child + : React.isValidElement(child) + ? getTextContent(child) + : '' ) - }, - - code: ({ - inline, - className, - children, - ...props - }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { - if (inline) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) - }, - - strong: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - b: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - em: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - i: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - blockquote: ({ children }: React.HTMLAttributes) => ( -
    - {children} -
    - ), - - hr: () =>
    , - - a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( - - {children} - - ), - - table: ({ children }: React.TableHTMLAttributes) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - tbody: ({ children }: React.HTMLAttributes) => ( - {children} - ), - tr: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - th: ({ children }: React.ThHTMLAttributes) => ( - - {children} - - ), - td: ({ children }: React.TdHTMLAttributes) => ( - - {children} - - ), + .join('') + } else { + actualCodeText = String(codeContent || '') + } - img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( - {alt - ), - }), - [copiedCodeBlocks] - ) + return + }, + + // Inline code + code: ({ + className, + children, + ...props + }: React.HTMLAttributes & { className?: string }) => ( + + {children} + + ), + + // Text formatting + strong: ({ children }: React.HTMLAttributes) => ( + {children} + ), + b: ({ children }: React.HTMLAttributes) => ( + {children} + ), + em: ({ children }: React.HTMLAttributes) => ( + {children} + ), + i: ({ children }: React.HTMLAttributes) => ( + {children} + ), + + // Blockquote - compact + blockquote: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
    + ), + + // Horizontal rule + hr: () =>
    , + + // Links + a: ({ href, children }: React.AnchorHTMLAttributes) => ( + {children} + ), + + // Tables - compact + table: ({ children }: React.TableHTMLAttributes) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }: React.HTMLAttributes) => ( + + {children} + + ), + tbody: ({ children }: React.HTMLAttributes) => ( + {children} + ), + tr: ({ children }: React.HTMLAttributes) => ( + {children} + ), + th: ({ children }: React.ThHTMLAttributes) => ( + + {children} + + ), + td: ({ children }: React.TdHTMLAttributes) => ( + + {children} + + ), + + // Images + img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( + {alt + ), +} +/** + * CopilotMarkdownRenderer renders markdown content with custom styling + * Optimized for LLM chat: tight spacing, memoized components, isolated state + * + * @param props - Component props + * @returns Rendered markdown content + */ +function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { return ( -
    +
    {content}
    ) } + +export default memo(CopilotMarkdownRenderer) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx index c4d49dd008..8a74180999 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx @@ -44,22 +44,6 @@ export const StreamingIndicator = memo(() => ( StreamingIndicator.displayName = 'StreamingIndicator' -/** - * InlineStreamingDots shows small animated dots inline with text - * Used at the end of streaming content to indicate more is coming - */ -const InlineStreamingDots = memo(() => ( - - - - - - - -)) - -InlineStreamingDots.displayName = 'InlineStreamingDots' - /** * Props for the SmoothStreamingText component */ @@ -68,8 +52,6 @@ interface SmoothStreamingTextProps { content: string /** Whether the content is actively streaming */ isStreaming: boolean - /** Whether to show inline streaming dots at the end of content. Defaults to true. */ - showIndicator?: boolean } /** @@ -110,7 +92,7 @@ function calculateAdaptiveDelay(displayedLength: number, totalLength: number): n * @returns Streaming text with smooth animation */ export const SmoothStreamingText = memo( - ({ content, isStreaming, showIndicator = true }: SmoothStreamingTextProps) => { + ({ content, isStreaming }: SmoothStreamingTextProps) => { const [displayedContent, setDisplayedContent] = useState('') const contentRef = useRef(content) const rafRef = useRef(null) @@ -178,23 +160,15 @@ export const SmoothStreamingText = memo( } }, [content, isStreaming]) - // Show inline dots when streaming and we have some content displayed (if enabled) - const showInlineDots = showIndicator && isStreaming && displayedContent.length > 0 - return ( -
    +
    - {showInlineDots && }
    ) }, (prevProps, nextProps) => { // Prevent re-renders during streaming unless content actually changed - return ( - prevProps.content === nextProps.content && - prevProps.isStreaming === nextProps.isStreaming && - prevProps.showIndicator === nextProps.showIndicator - ) + return prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 9ded75dba2..1c2d9b631f 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 @@ -1,25 +1,152 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' -import CopilotMarkdownRenderer from './markdown-renderer' /** * Max height for thinking content before internal scrolling kicks in */ -const THINKING_MAX_HEIGHT = 200 +const THINKING_MAX_HEIGHT = 150 + +/** + * Height threshold before gradient fade kicks in + */ +const GRADIENT_THRESHOLD = 100 /** * Interval for auto-scroll during streaming (ms) */ -const SCROLL_INTERVAL = 100 +const SCROLL_INTERVAL = 50 /** * Timer update interval in milliseconds */ const TIMER_UPDATE_INTERVAL = 100 +/** + * Thinking text streaming - much faster than main text + * Essentially instant with minimal delay + */ +const THINKING_DELAY = 0.5 +const THINKING_CHARS_PER_FRAME = 3 + +/** + * Props for the SmoothThinkingText component + */ +interface SmoothThinkingTextProps { + content: string + isStreaming: boolean +} + +/** + * SmoothThinkingText renders thinking content with fast streaming animation + * Uses gradient fade at top when content is tall enough + */ +const SmoothThinkingText = memo( + ({ content, isStreaming }: SmoothThinkingTextProps) => { + const [displayedContent, setDisplayedContent] = useState('') + const [showGradient, setShowGradient] = useState(false) + const contentRef = useRef(content) + const textRef = useRef(null) + const rafRef = useRef(null) + const indexRef = useRef(0) + const lastFrameTimeRef = useRef(0) + const isAnimatingRef = useRef(false) + + useEffect(() => { + contentRef.current = content + + if (content.length === 0) { + setDisplayedContent('') + indexRef.current = 0 + return + } + + if (isStreaming) { + if (indexRef.current < content.length && !isAnimatingRef.current) { + isAnimatingRef.current = true + lastFrameTimeRef.current = performance.now() + + const animateText = (timestamp: number) => { + const currentContent = contentRef.current + const currentIndex = indexRef.current + const elapsed = timestamp - lastFrameTimeRef.current + + if (elapsed >= THINKING_DELAY) { + if (currentIndex < currentContent.length) { + // Reveal multiple characters per frame for faster streaming + const newIndex = Math.min(currentIndex + THINKING_CHARS_PER_FRAME, currentContent.length) + const newDisplayed = currentContent.slice(0, newIndex) + setDisplayedContent(newDisplayed) + indexRef.current = newIndex + lastFrameTimeRef.current = timestamp + } + } + + if (indexRef.current < currentContent.length) { + rafRef.current = requestAnimationFrame(animateText) + } else { + isAnimatingRef.current = false + } + } + + rafRef.current = requestAnimationFrame(animateText) + } + } else { + // Streaming ended - show full content immediately + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + setDisplayedContent(content) + indexRef.current = content.length + isAnimatingRef.current = false + } + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + isAnimatingRef.current = false + } + }, [content, isStreaming]) + + // Check if content height exceeds threshold for gradient + useEffect(() => { + if (textRef.current && isStreaming) { + const height = textRef.current.scrollHeight + setShowGradient(height > GRADIENT_THRESHOLD) + } else { + setShowGradient(false) + } + }, [displayedContent, isStreaming]) + + // Apply vertical gradient fade at the top only when content is tall enough + const gradientStyle = + isStreaming && showGradient + ? { + maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)', + WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)', + } + : undefined + + return ( +
    + {displayedContent} +
    + ) + }, + (prevProps, nextProps) => { + return prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + } +) + +SmoothThinkingText.displayName = 'SmoothThinkingText' + /** * Props for the ThinkingBlock component */ @@ -113,14 +240,14 @@ export function ThinkingBlock({ const isNearBottom = distanceFromBottom <= 20 const delta = scrollTop - lastScrollTopRef.current - const movedUp = delta < -2 + const movedUp = delta < -1 if (movedUp && !isNearBottom) { setUserHasScrolledAway(true) } - // Re-stick if user scrolls back to bottom - if (userHasScrolledAway && isNearBottom) { + // Re-stick if user scrolls back to bottom with intent + if (userHasScrolledAway && isNearBottom && delta > 10) { setUserHasScrolledAway(false) } @@ -134,7 +261,6 @@ export function ThinkingBlock({ }, [isExpanded, userHasScrolledAway]) // Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away - // This matches the main chat behavior in useScrollManagement useEffect(() => { if (!isStreaming || !isExpanded || userHasScrolledAway) return @@ -142,16 +268,14 @@ export function ThinkingBlock({ const container = scrollContainerRef.current if (!container) return - // Always scroll to bottom during streaming (like main chat does) - // User can break out by scrolling up, which sets userHasScrolledAway programmaticScrollRef.current = true container.scrollTo({ top: container.scrollHeight, - behavior: 'smooth', + behavior: 'auto', }) window.setTimeout(() => { programmaticScrollRef.current = false - }, 150) + }, 16) }, SCROLL_INTERVAL) return () => window.clearInterval(intervalId) @@ -238,15 +362,11 @@ export function ThinkingBlock({
    - {/* Render markdown during streaming with thinking text styling */} -
    - - -
    +
    ) @@ -278,13 +398,13 @@ export function ThinkingBlock({
    - {/* Use markdown renderer for completed content */} -
    - + {/* Completed thinking text - dimmed */} +
    + {content}
    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 01bd6b291b..180c040efb 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 @@ -209,7 +209,7 @@ const CopilotMessage: FC = memo( const blockKey = `text-${index}-${block.timestamp || index}` return ( -
    +
    {shouldUseSmoothing ? ( ) : ( @@ -485,8 +485,8 @@ const CopilotMessage: FC = memo( {/* Content blocks in chronological order */} {memoizedContentBlocks} - {/* Only show streaming indicator when no content has arrived yet */} - {isStreaming && !hasVisibleContent && } + {/* Always show streaming indicator at the end while streaming */} + {isStreaming && } {message.errorType === 'usage_limit' && (
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 3a544eae5a..c7b5de52c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -221,7 +221,7 @@ function PlanSteps({
    {streaming && isLastStep ? ( - + ) : ( )} @@ -363,7 +363,7 @@ export function OptionsSelector({ )} > {streaming ? ( - + ) : ( )} @@ -1066,7 +1066,7 @@ function SubAgentContent({
    @@ -1326,7 +1326,7 @@ function SubagentContentRenderer({
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts index 658036555e..5959368e3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts @@ -39,49 +39,42 @@ export function useScrollManagement( const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false) const programmaticScrollInProgressRef = useRef(false) const lastScrollTopRef = useRef(0) - const lastScrollHeightRef = useRef(0) - const rafIdRef = useRef(null) const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth' const stickinessThreshold = options?.stickinessThreshold ?? 100 + /** + * Scrolls the container to the bottom with smooth animation + */ const getScrollContainer = useCallback((): HTMLElement | null => { + // Prefer the element with the ref (our scrollable div) if (scrollAreaRef.current) return scrollAreaRef.current return null }, []) - /** - * Scrolls the container to the bottom - * Uses 'auto' for streaming to prevent jitter, 'smooth' for user actions - */ - const scrollToBottom = useCallback( - (forceInstant = false) => { - const scrollContainer = getScrollContainer() - if (!scrollContainer) return + const scrollToBottom = useCallback(() => { + const scrollContainer = getScrollContainer() + if (!scrollContainer) return - programmaticScrollInProgressRef.current = true - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: forceInstant ? 'auto' : scrollBehavior, - }) - // Reset flag after scroll completes - window.setTimeout( - () => { - programmaticScrollInProgressRef.current = false - }, - forceInstant ? 16 : 200 - ) - }, - [getScrollContainer, scrollBehavior] - ) + programmaticScrollInProgressRef.current = true + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: scrollBehavior, + }) + // Best-effort reset; not all browsers fire scrollend reliably + window.setTimeout(() => { + programmaticScrollInProgressRef.current = false + }, 200) + }, [getScrollContainer, scrollBehavior]) /** - * Handles scroll events to track user position + * Handles scroll events to track user position and show/hide scroll button */ const handleScroll = useCallback(() => { const scrollContainer = getScrollContainer() if (!scrollContainer) return if (programmaticScrollInProgressRef.current) { + // Ignore scrolls we initiated return } @@ -93,18 +86,21 @@ export function useScrollManagement( if (isSendingMessage) { const delta = scrollTop - lastScrollTopRef.current - const movedUp = delta < -2 + const movedUp = delta < -2 // small hysteresis to avoid noise + const movedDown = delta > 2 if (movedUp) { + // Any upward movement breaks away from sticky during streaming setUserHasScrolledDuringStream(true) } - // Re-stick if user scrolls back to bottom - if (userHasScrolledDuringStream && nearBottom && delta > 2) { + // If the user has broken away and scrolls back down to the bottom, re-stick + if (userHasScrolledDuringStream && movedDown && nearBottom) { setUserHasScrolledDuringStream(false) } } + // Track last scrollTop for direction detection lastScrollTopRef.current = scrollTop }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold]) @@ -113,80 +109,95 @@ export function useScrollManagement( const scrollContainer = getScrollContainer() if (!scrollContainer) return - scrollContainer.addEventListener('scroll', handleScroll, { passive: true }) + const handleUserScroll = () => { + handleScroll() + } + + scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true }) + + if ('onscrollend' in scrollContainer) { + scrollContainer.addEventListener('scrollend', handleScroll, { passive: true }) + } + + // Initialize state + window.setTimeout(handleScroll, 100) + // Initialize last scroll position lastScrollTopRef.current = scrollContainer.scrollTop - lastScrollHeightRef.current = scrollContainer.scrollHeight return () => { - scrollContainer.removeEventListener('scroll', handleScroll) + scrollContainer.removeEventListener('scroll', handleUserScroll) + if ('onscrollend' in scrollContainer) { + scrollContainer.removeEventListener('scrollend', handleScroll) + } } }, [getScrollContainer, handleScroll]) - // Scroll on new user message + // Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming useEffect(() => { if (messages.length === 0) return + const lastMessage = messages[messages.length - 1] + const isNewUserMessage = lastMessage?.role === 'user' + + const shouldAutoScroll = + isNewUserMessage || + (isSendingMessage && !userHasScrolledDuringStream) || + (!isSendingMessage && isNearBottom) + + if (shouldAutoScroll) { + scrollToBottom() + } + }, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream, scrollToBottom]) + + // Reset user scroll state when streaming starts or when user sends a message + useEffect(() => { const lastMessage = messages[messages.length - 1] if (lastMessage?.role === 'user') { setUserHasScrolledDuringStream(false) - scrollToBottom() + programmaticScrollInProgressRef.current = false + const scrollContainer = getScrollContainer() + if (scrollContainer) { + lastScrollTopRef.current = scrollContainer.scrollTop + } } - }, [messages, scrollToBottom]) + }, [messages, getScrollContainer]) // Reset user scroll state when streaming completes const prevIsSendingRef = useRef(false) useEffect(() => { if (prevIsSendingRef.current && !isSendingMessage) { setUserHasScrolledDuringStream(false) - // Final scroll to ensure we're at bottom - if (isNearBottom) { - scrollToBottom() - } } prevIsSendingRef.current = isSendingMessage - }, [isSendingMessage, isNearBottom, scrollToBottom]) + }, [isSendingMessage]) - // While streaming, use RAF to check for content changes and scroll - // This is more efficient than setInterval and syncs with browser rendering + // While streaming and not broken away, keep pinned to bottom useEffect(() => { - if (!isSendingMessage || userHasScrolledDuringStream) { - if (rafIdRef.current) { - cancelAnimationFrame(rafIdRef.current) - rafIdRef.current = null - } - return - } + if (!isSendingMessage || userHasScrolledDuringStream) return - const checkAndScroll = () => { + const intervalId = window.setInterval(() => { const scrollContainer = getScrollContainer() - if (!scrollContainer) { - rafIdRef.current = requestAnimationFrame(checkAndScroll) - return - } + if (!scrollContainer) return - const { scrollHeight } = scrollContainer - // Only scroll if content height actually changed - if (scrollHeight !== lastScrollHeightRef.current) { - lastScrollHeightRef.current = scrollHeight - // Use instant scroll during streaming to prevent jitter - scrollToBottom(true) + const { scrollTop, scrollHeight, clientHeight } = scrollContainer + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const nearBottom = distanceFromBottom <= stickinessThreshold + if (nearBottom) { + scrollToBottom() } + }, 100) - rafIdRef.current = requestAnimationFrame(checkAndScroll) - } - - rafIdRef.current = requestAnimationFrame(checkAndScroll) - - return () => { - if (rafIdRef.current) { - cancelAnimationFrame(rafIdRef.current) - rafIdRef.current = null - } - } - }, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom]) + return () => window.clearInterval(intervalId) + }, [ + isSendingMessage, + userHasScrolledDuringStream, + getScrollContainer, + scrollToBottom, + stickinessThreshold, + ]) return { scrollAreaRef, - scrollToBottom: () => scrollToBottom(false), + scrollToBottom, } } From c8865fa3959517eacf4258341cd72c6d123a0bb7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 12:18:12 -0800 Subject: [PATCH 12/16] Subagent parallelization --- .../components/thinking-block.tsx | 11 ++++--- .../copilot-message/copilot-message.tsx | 5 ++- apps/sim/stores/panel/copilot/store.ts | 32 +++++++++---------- 3 files changed, 26 insertions(+), 22 deletions(-) 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 1c2d9b631f..3a79a1fda9 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 @@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' +import CopilotMarkdownRenderer from './markdown-renderer' /** * Max height for thinking content before internal scrolling kicks in @@ -133,10 +134,10 @@ const SmoothThinkingText = memo( return (
    - {displayedContent} +
    ) }, @@ -402,9 +403,9 @@ export function ThinkingBlock({ isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' )} > - {/* Completed thinking text - dimmed */} -
    - {content} + {/* Completed thinking text - dimmed with markdown */} +
    +
    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 180c040efb..85f2db0479 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 @@ -221,6 +221,8 @@ const CopilotMessage: FC = memo( if (block.type === 'thinking') { // Check if there are any blocks after this one (tool calls, text, etc.) const hasFollowingContent = index < message.contentBlocks!.length - 1 + // Check if special tags (options, plan) are present - should also close thinking + const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan) const blockKey = `thinking-${index}-${block.timestamp || index}` return ( @@ -229,6 +231,7 @@ const CopilotMessage: FC = memo( content={block.content} isStreaming={isStreaming} hasFollowingContent={hasFollowingContent} + hasSpecialTags={hasSpecialTags} />
    ) @@ -244,7 +247,7 @@ const CopilotMessage: FC = memo( } return null }) - }, [message.contentBlocks, isStreaming]) + }, [message.contentBlocks, isStreaming, parsedTags]) if (isUser) { return ( diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 88fdfde8d7..f477d73222 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1863,7 +1863,7 @@ const subAgentSSEHandlers: Record = { updateToolCallWithSubAgentData(context, get, set, parentToolCallId) - // Execute client tools (same logic as main tool_call handler) + // Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler try { const def = getTool(name) if (def) { @@ -1872,29 +1872,29 @@ const subAgentSSEHandlers: Record = { ? !!def.hasInterrupt(args || {}) : !!def.hasInterrupt if (!hasInterrupt) { - // Auto-execute tools without interrupts + // Auto-execute tools without interrupts - non-blocking const ctx = createExecutionContext({ toolCallId: id, toolName: name }) - try { - await def.execute(ctx, args || {}) - } catch (execErr: any) { - logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) - } + Promise.resolve() + .then(() => def.execute(ctx, args || {})) + .catch((execErr: any) => { + logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) + }) } } else { - // Fallback to class-based tools + // Fallback to class-based tools - non-blocking const instance = getClientTool(id) if (instance) { const hasInterruptDisplays = !!instance.getInterruptDisplays?.() if (!hasInterruptDisplays) { - try { - await instance.execute(args || {}) - } catch (execErr: any) { - logger.error('[SubAgent] Class tool execution failed', { - id, - name, - error: execErr?.message, + Promise.resolve() + .then(() => instance.execute(args || {})) + .catch((execErr: any) => { + logger.error('[SubAgent] Class tool execution failed', { + id, + name, + error: execErr?.message, + }) }) - } } } } From 6fdb45a6b1d49ba61c93e98563488435ddbcbb05 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 12:31:52 -0800 Subject: [PATCH 13/16] Fix ui --- .../copilot-message/components/thinking-block.tsx | 6 +++--- .../copilot/components/copilot-message/copilot-message.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 3a79a1fda9..61df4cc11a 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 @@ -194,8 +194,8 @@ export function ThinkingBlock({ * Auto-collapses when streaming ends OR when following content arrives */ useEffect(() => { - // Collapse if streaming ended or if there's following content (like a tool call) - if (!isStreaming || hasFollowingContent) { + // Collapse if streaming ended, there's following content, or special tags arrived + if (!isStreaming || hasFollowingContent || hasSpecialTags) { setIsExpanded(false) userCollapsedRef.current = false setUserHasScrolledAway(false) @@ -205,7 +205,7 @@ export function ThinkingBlock({ if (!userCollapsedRef.current && content && content.trim().length > 0) { setIsExpanded(true) } - }, [isStreaming, content, hasFollowingContent]) + }, [isStreaming, content, hasFollowingContent, hasSpecialTags]) // Reset start time when streaming begins useEffect(() => { 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 85f2db0479..be3af2f886 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 @@ -484,11 +484,11 @@ const CopilotMessage: FC = memo( className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`} style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties} > -
    +
    {/* Content blocks in chronological order */} {memoizedContentBlocks} - {/* Always show streaming indicator at the end while streaming */} + {/* Streaming indicator always at bottom during streaming */} {isStreaming && } {message.errorType === 'usage_limit' && ( From 9c27950f0ab799a88a0ce1826e5f872ca4c97909 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 12:32:03 -0800 Subject: [PATCH 14/16] Fix lint --- .../components/markdown-renderer.tsx | 14 ++++----- .../components/smooth-streaming.tsx | 4 ++- .../components/thinking-block.tsx | 9 ++++-- .../components/tool-call/tool-call.tsx | 2 +- .../components/slash-menu/slash-menu.tsx | 4 ++- .../components/user-input/user-input.tsx | 31 ++++++++++++++++--- .../tools/client/blocks/get-block-config.ts | 2 +- .../tools/client/blocks/get-block-options.ts | 2 +- .../tools/client/other/crawl-website.ts | 1 - .../tools/client/other/get-page-contents.ts | 1 - .../copilot/tools/client/other/scrape-page.ts | 1 - .../copilot/tools/client/other/superagent.ts | 1 - apps/sim/stores/panel/copilot/store.ts | 12 ++++--- 13 files changed, 56 insertions(+), 28 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 c64d35680f..dc3299c50f 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 @@ -158,7 +158,7 @@ const markdownComponents = { // Headings - minimal margins for chat context h1: ({ children }: React.HTMLAttributes) => ( -

    +

    {children}

    ), @@ -168,12 +168,12 @@ const markdownComponents = { ), h3: ({ children }: React.HTMLAttributes) => ( -

    +

    {children}

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

    +

    {children}

    ), @@ -197,7 +197,7 @@ const markdownComponents = { ), li: ({ children }: React.LiHTMLAttributes) => (
  • {children} @@ -295,9 +295,7 @@ const markdownComponents = {
  • ), thead: ({ children }: React.HTMLAttributes) => ( - - {children} - + {children} ), tbody: ({ children }: React.HTMLAttributes) => ( {children} @@ -331,7 +329,7 @@ const markdownComponents = { */ function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { return ( -
    +
    {content} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx index 8a74180999..7dfe9af4ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx @@ -168,7 +168,9 @@ export const SmoothStreamingText = memo( }, (prevProps, nextProps) => { // Prevent re-renders during streaming unless content actually changed - return prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + return ( + prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + ) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 61df4cc11a..fbb7065f90 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 @@ -77,7 +77,10 @@ const SmoothThinkingText = memo( if (elapsed >= THINKING_DELAY) { if (currentIndex < currentContent.length) { // Reveal multiple characters per frame for faster streaming - const newIndex = Math.min(currentIndex + THINKING_CHARS_PER_FRAME, currentContent.length) + const newIndex = Math.min( + currentIndex + THINKING_CHARS_PER_FRAME, + currentContent.length + ) const newDisplayed = currentContent.slice(0, newIndex) setDisplayedContent(newDisplayed) indexRef.current = newIndex @@ -142,7 +145,9 @@ const SmoothThinkingText = memo( ) }, (prevProps, nextProps) => { - return prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + return ( + prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + ) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index c7b5de52c9..4d54d98d8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1973,7 +1973,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: 'tour', 'info', 'workflow', - 'superagent' + 'superagent', ] const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx index 15bb2a7d04..a50de3c1bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -229,7 +229,9 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr id='web' title='Web' onOpen={() => setOpenSubmenuFor('Web')} - active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length} + active={ + isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length + } data-idx={TOP_LEVEL_COMMANDS.length} > {WEB_COMMANDS.map((cmd) => ( 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 1be5441b2d..5c0a56e30c 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 @@ -430,7 +430,13 @@ const UserInput = forwardRef( const last = WEB_COMMANDS.length - 1 mentionMenu.setSubmenuActiveIndex((prev) => { const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) return next }) @@ -441,7 +447,13 @@ const UserInput = forwardRef( mentionMenu.setSubmenuActiveIndex((prev) => { if (filtered.length === 0) return 0 const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) return next }) @@ -451,7 +463,13 @@ const UserInput = forwardRef( const last = totalItems - 1 mentionMenu.setMentionActiveIndex((prev) => { const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) return next }) @@ -502,7 +520,8 @@ const UserInput = forwardRef( if (mentionMenu.openSubmenuFor === 'Web') { // Select from Web submenu - const selectedCommand = WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0] + const selectedCommand = + WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0] handleSlashCommandSelect(selectedCommand) } else if (showAggregatedView) { // Select from filtered view @@ -831,7 +850,9 @@ const UserInput = forwardRef( (disabled || isLoading) && 'cursor-not-allowed' )} > - / + + / + {/* Selected Context Pills */} diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts index 443450aaed..be4196c443 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react' -import { getBlock } from '@/blocks/registry' import { BaseClientTool, type BaseClientToolMetadata, @@ -11,6 +10,7 @@ import { GetBlockConfigInput, GetBlockConfigResult, } from '@/lib/copilot/tools/shared/schemas' +import { getBlock } from '@/blocks/registry' interface GetBlockConfigArgs { blockType: string diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index b097eef568..a104688e5f 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' -import { getBlock } from '@/blocks/registry' import { BaseClientTool, type BaseClientToolMetadata, @@ -11,6 +10,7 @@ import { GetBlockOptionsInput, GetBlockOptionsResult, } from '@/lib/copilot/tools/shared/schemas' +import { getBlock } from '@/blocks/registry' interface GetBlockOptionsArgs { blockId: string diff --git a/apps/sim/lib/copilot/tools/client/other/crawl-website.ts b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts index e6ab7d46a9..5fee1690dd 100644 --- a/apps/sim/lib/copilot/tools/client/other/crawl-website.ts +++ b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts @@ -51,4 +51,3 @@ export class CrawlWebsiteClientTool extends BaseClientTool { return } } - diff --git a/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts index 50c811a8e1..a5ffa6eeb2 100644 --- a/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts +++ b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts @@ -52,4 +52,3 @@ export class GetPageContentsClientTool extends BaseClientTool { return } } - diff --git a/apps/sim/lib/copilot/tools/client/other/scrape-page.ts b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts index f093d061d7..0bb5f72a7e 100644 --- a/apps/sim/lib/copilot/tools/client/other/scrape-page.ts +++ b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts @@ -51,4 +51,3 @@ export class ScrapePageClientTool extends BaseClientTool { return } } - diff --git a/apps/sim/lib/copilot/tools/client/other/superagent.ts b/apps/sim/lib/copilot/tools/client/other/superagent.ts index d75fe16c48..99ec1fbfe1 100644 --- a/apps/sim/lib/copilot/tools/client/other/superagent.ts +++ b/apps/sim/lib/copilot/tools/client/other/superagent.ts @@ -54,4 +54,3 @@ export class SuperagentClientTool extends BaseClientTool { // Register UI config at module load registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!) - diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index f477d73222..9ffaab7a0d 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -27,11 +27,13 @@ import { import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui' import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth' import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo' +import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website' import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool' import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug' import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy' import { EditClientTool } from '@/lib/copilot/tools/client/other/edit' import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate' +import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents' import { InfoClientTool } from '@/lib/copilot/tools/client/other/info' import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge' import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request' @@ -40,12 +42,10 @@ import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/o import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug' import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research' +import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs' -import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website' -import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents' -import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page' import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online' import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns' import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep' @@ -1877,7 +1877,11 @@ const subAgentSSEHandlers: Record = { Promise.resolve() .then(() => def.execute(ctx, args || {})) .catch((execErr: any) => { - logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) + logger.error('[SubAgent] Tool execution failed', { + id, + name, + error: execErr?.message, + }) }) } } else { From eb099eb8be4eed5c46811a90fecd2e142bbcbce4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 16:08:07 -0800 Subject: [PATCH 15/16] Fix superagent --- .../components/tool-call/tool-call.tsx | 30 +++++++--------- apps/sim/stores/panel/copilot/store.ts | 36 ++++++++----------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 4d54d98d8b..7215c10d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1636,10 +1636,8 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { * Checks if a tool is an integration tool (server-side executed, not a client tool) */ function isIntegrationTool(toolName: string): boolean { - // Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools) - const isClientTool = !!CLASS_TOOL_METADATA[toolName] - const isRegisteredTool = !!getRegisteredTools()[toolName] - return !isClientTool && !isRegisteredTool + // Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution) + return !CLASS_TOOL_METADATA[toolName] } function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { @@ -1668,16 +1666,9 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { return true } - // Also show buttons for integration tools in pending state (they need user confirmation) - // But NOT if the tool is auto-allowed (it will auto-execute) + // Always show buttons for integration tools in pending state (they need user confirmation) const mode = useCopilotStore.getState().mode - const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name) - if ( - mode === 'build' && - isIntegrationTool(toolCall.name) && - toolCall.state === 'pending' && - !isAutoAllowed - ) { + if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') { return true } @@ -1899,15 +1890,20 @@ function RunSkipButtons({ if (buttonsHidden) return null - // Standardized buttons for all interrupt tools: Allow, Always Allow, Skip + // Hide "Always Allow" for integration tools (only show for client tools with interrupts) + const showAlwaysAllow = !isIntegrationTool(toolCall.name) + + // Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip return (
    - + {showAlwaysAllow && ( + + )} diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 9ffaab7a0d..64d1d3e7bf 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1223,30 +1223,20 @@ const sseHandlers: Record = { } } catch {} - // Integration tools: Check if auto-allowed, otherwise wait for user confirmation - // This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry + // Integration tools: Stay in pending state until user confirms via buttons + // This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry // Only relevant if mode is 'build' (agent) - const { mode, workflowId, autoAllowedTools } = get() + const { mode, workflowId } = get() if (mode === 'build' && workflowId) { - // Check if tool was NOT found in client registry (def is undefined from above) + // Check if tool was NOT found in client registry const def = name ? getTool(name) : undefined const inst = getClientTool(id) as any if (!def && !inst && name) { - // Check if this tool is auto-allowed - if (autoAllowedTools.includes(name)) { - logger.info('[build mode] Integration tool auto-allowed, executing', { id, name }) - - // Auto-execute the tool - setTimeout(() => { - get().executeIntegrationTool(id) - }, 0) - } else { - // Integration tools stay in pending state until user confirms - logger.info('[build mode] Integration tool awaiting user confirmation', { - id, - name, - }) - } + // Integration tools stay in pending state until user confirms + logger.info('[build mode] Integration tool awaiting user confirmation', { + id, + name, + }) } } }, @@ -2639,13 +2629,14 @@ export const useCopilotStore = create()( ), isSendingMessage: false, isAborting: false, - abortController: null, + // Keep abortController so streaming loop can check signal.aborted + // It will be nulled when streaming completes or new message starts })) } else { set({ isSendingMessage: false, isAborting: false, - abortController: null, + // Keep abortController so streaming loop can check signal.aborted }) } @@ -2674,7 +2665,7 @@ export const useCopilotStore = create()( } catch {} } } catch { - set({ isSendingMessage: false, isAborting: false, abortController: null }) + set({ isSendingMessage: false, isAborting: false }) } }, @@ -3175,6 +3166,7 @@ export const useCopilotStore = create()( : msg ), isSendingMessage: false, + isAborting: false, abortController: null, currentUserMessageId: null, })) From 445c9c1fdcfab7188fc536388b127d0b0575992b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 13 Jan 2026 16:12:09 -0800 Subject: [PATCH 16/16] Dont collapse info and super --- .../components/copilot/components/tool-call/tool-call.tsx | 6 +++--- .../components/copilot/components/user-input/user-input.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 7215c10d7f..0cf54c016d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1162,10 +1162,10 @@ function SubAgentThinkingContent({ /** * Subagents that should collapse when done streaming. - * Default behavior is to NOT collapse (stay expanded like edit). - * Only these specific subagents collapse into "Planned for Xs >" style headers. + * Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.). + * Only plan, debug, and research collapse into summary headers. */ -const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info', 'superagent']) +const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research']) /** * SubagentContentRenderer handles the rendering of subagent content. 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 5c0a56e30c..2d16d1c6f8 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 @@ -509,7 +509,7 @@ const UserInput = forwardRef( if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() if (showSlashMenu) { - const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy', 'research'] + const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent'] const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]