From 5c4454b675b41003c39f270160a5d32515d91641 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 29 Jul 2026 21:43:54 -0700 Subject: [PATCH 01/26] feat(chat): highlight-to-chat for file and table selections Adds an IDE-style "add to chat" affordance so the Sim agent can reference an exact passage of a file or a specific set of table rows/cells instead of a whole resource. - Two new ChatContext kinds: file_selection (inline selected text + line range) and table_selection (authoritative row ids, optional column ids; the server re-fetches current rows by id). Chip registry, client serializer, boundary contract, and server resolver all extend along their existing switch(kind) seams. - Producers: Monaco context menu, Tiptap bubble menu, and the table grid context menu, all using the Sim Chat block icon. Adding a selection opens the split slideover (chat + resource) with the chip dropped straight into the input; from a standalone Files/Tables page the context is stashed and drained on chat mount. - Cmd+C / Cmd+V: a selection copied from a file/table rides a custom text/x-sim-selection clipboard MIME and pastes into chat as the same reference chip; the chat input round-trips a sole selection chip on copy/cut too. - Guards: selection payloads are length/row/column bounded and truncated within the schema bound; labels carry a deterministic key so distinct same-size selections don't collide; a shared resource tab closes only once no remaining chip references it; stale cell-range column ids drop the context rather than dumping the full table. --- .../file-viewer/editor-context-menu.tsx | 14 +- .../menus/bubble-menu.tsx | 20 +- .../menus/toolbar-button.tsx | 5 +- .../rich-markdown-editor.tsx | 46 ++++- .../components/file-viewer/text-editor.tsx | 41 +++++ .../file-viewer/use-selection-copy-bridge.ts | 36 ++++ .../chat-context-kind-registry.tsx | 14 ++ .../chat-surface-context.tsx | 15 +- .../components/chip-clipboard-codec.ts | 25 +++ .../prompt-editor/use-prompt-editor.ts | 97 +++++++++- .../home/components/user-input/user-input.tsx | 24 ++- .../app/workspace/[workspaceId]/home/home.tsx | 57 +++++- .../[workspaceId]/home/hooks/use-chat.ts | 21 +++ .../app/workspace/[workspaceId]/home/types.ts | 9 + .../components/context-menu/context-menu.tsx | 16 ++ .../components/table-grid/table-grid.tsx | 162 +++++++++++++++++ apps/sim/hooks/use-add-to-chat.ts | 34 ++++ apps/sim/lib/copilot/chat/post.ts | 12 ++ .../lib/copilot/chat/process-contents.test.ts | 172 +++++++++++++++++- apps/sim/lib/copilot/chat/process-contents.ts | 121 ++++++++++++ .../copilot/chat/selection-clipboard.test.ts | 94 ++++++++++ .../lib/copilot/chat/selection-clipboard.ts | 59 ++++++ .../copilot/chat/selection-context.test.ts | 79 ++++++++ .../sim/lib/copilot/chat/selection-context.ts | 109 +++++++++++ apps/sim/lib/core/utils/browser-storage.ts | 57 ++++++ apps/sim/lib/mothership/events.ts | 33 ++++ apps/sim/lib/table/rows/service.ts | 30 +++ apps/sim/stores/panel/types.ts | 22 +++ 28 files changed, 1402 insertions(+), 22 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts create mode 100644 apps/sim/hooks/use-add-to-chat.ts create mode 100644 apps/sim/lib/copilot/chat/selection-clipboard.test.ts create mode 100644 apps/sim/lib/copilot/chat/selection-clipboard.ts create mode 100644 apps/sim/lib/copilot/chat/selection-context.test.ts create mode 100644 apps/sim/lib/copilot/chat/selection-context.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx index 904d3c06a11..3f1fb5bd805 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '@sim/emcn' -import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' +import { Blimp, Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' import { Scissors } from 'lucide-react' interface EditorContextMenuProps { @@ -23,6 +23,8 @@ interface EditorContextMenuProps { onPaste: () => void onSelectAll: () => void onFind: () => void + /** Adds the current selection to Chat as a reference. Omit to hide the item. */ + onAddToChat?: () => void } export function EditorContextMenu({ @@ -37,6 +39,7 @@ export function EditorContextMenu({ onPaste, onSelectAll, onFind, + onAddToChat, }: EditorContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -60,6 +63,15 @@ export function EditorContextMenu({ sideOffset={2} onCloseAutoFocus={(e) => e.preventDefault()} > + {onAddToChat && ( + <> + + + Add to chat + + + + )} {canEdit && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 56b4dd78389..8036172017c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { Blimp } from '@sim/emcn/icons' import { posToDOMRect } from '@tiptap/core' import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' @@ -54,6 +55,8 @@ interface EditorBubbleMenuProps { editor: Editor /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ scrollContainerRef: React.RefObject + /** Adds the current selection to Chat as a reference. Omit to hide the action. */ + onAddToChat?: () => void } /** @@ -62,7 +65,11 @@ interface EditorBubbleMenuProps { * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar * stays correct without re-rendering the editor on every transaction. */ -export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) { +export function EditorBubbleMenu({ + editor, + scrollContainerRef, + onAddToChat, +}: EditorBubbleMenuProps) { const [linkValue, setLinkValue] = useState(null) const linkInputRef = useRef(null) const linkRangeRef = useRef<{ from: number; to: number } | null>(null) @@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen ) : ( <> + {onAddToChat && ( + <> + + + + )} > label: string shortcut?: string isActive?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9107dbd795d..addc0a6145f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { Fragment, Slice } from '@tiptap/pm/model' @@ -9,13 +9,21 @@ import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' +import { + buildFileSelectionLabel, + selectionKey, + truncateSelectionText, +} from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' +import { useAddToChat } from '@/hooks/use-add-to-chat' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' +import type { ChatContext } from '@/stores/panel' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' +import { useSelectionCopyBridge } from '../use-selection-copy-bridge' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' import { @@ -557,12 +565,46 @@ export function LoadedRichMarkdownEditor({ [] ) + const addToChat = useAddToChat() + const buildSelectionContext = useCallback((): ChatContext | null => { + if (!editor) return null + const { from, to } = editor.state.selection + if (from === to) return null + const text = editor.state.doc.textBetween(from, to, '\n') + if (!text.trim()) return null + // Markdown has no native line numbers; approximate from newlines before the + // selection so repeated selections in the same file get distinct labels. + const startLine = editor.state.doc.textBetween(0, from, '\n').split('\n').length + const endLine = startLine + text.split('\n').length - 1 + return { + kind: 'file_selection', + fileId: file.id, + label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + text: truncateSelectionText(text), + startLine, + endLine, + } + }, [editor, file.id, file.name]) + + const handleAddSelectionToChat = useCallback(() => { + const context = buildSelectionContext() + if (context) addToChat(context) + }, [addToChat, buildSelectionContext]) + + useSelectionCopyBridge(containerRef, buildSelectionContext) + return (
- {editor && } + {editor && ( + + )} {editor && } {editor && } { + const editor = monacoEditorRef.current + const sel = editor?.getSelection() + const model = editor?.getModel() + if (!editor || !sel || sel.isEmpty() || !model) return null + const text = model.getValueInRange(sel) + if (!text.trim()) return null + const startLine = sel.startLineNumber + const endLine = sel.endLineNumber + return { + kind: 'file_selection', + fileId: file.id, + label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + text: truncateSelectionText(text), + startLine, + endLine, + } + }, [file.id, file.name]) + + const handleAddSelectionToChat = useCallback(() => { + const context = buildSelectionContext() + if (context) addToChat(context) + }, [addToChat, buildSelectionContext]) const { content, @@ -394,6 +427,10 @@ export const TextEditor = memo(function TextEditor({ }) contentRef.current = content + // Enable once content has loaded — the container (and Monaco) only mount after + // the `isContentLoading` early return below, so the bridge must (re-)attach then. + useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading) + useEffect(() => { const editor = monacoEditorRef.current if (!editor) return @@ -650,6 +687,10 @@ export const TextEditor = memo(function TextEditor({ onClose={closeContextMenu} hasSelection={contextMenu.hasSelection} canEdit={!isEditorReadOnly} + onAddToChat={() => { + handleAddSelectionToChat() + closeContextMenu() + }} onCut={() => { monacoEditorRef.current?.focus() monacoEditorRef.current?.trigger( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts new file mode 100644 index 00000000000..933ec91c1b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts @@ -0,0 +1,36 @@ +'use client' + +import { type RefObject, useEffect } from 'react' +import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import type { ChatContext } from '@/stores/panel' + +/** + * Rides a selection {@link ChatContext} onto the editor's native copy so a + * highlighted passage copied with Cmd+C pastes into Chat as a reference chip. + * + * The listener is attached to `containerRef` in the BUBBLE phase so it runs + * AFTER the inner editor's own copy handler (Monaco and ProseMirror both call + * `clearData()` then write `text/plain`/`text/html`) — the custom + * `text/x-sim-selection` type is added last and survives, leaving normal copy + * untouched. `buildContext` returns `null` when there is no non-empty selection. + * + * `enabled` lets a caller whose container mounts late (e.g. behind a loading + * gate) re-run the effect once the node exists — a ref object isn't reactive, so + * without it the effect would bail on the first render and never re-attach. + */ +export function useSelectionCopyBridge( + containerRef: RefObject, + buildContext: () => ChatContext | null, + enabled = true +): void { + useEffect(() => { + const dom = containerRef.current + if (!dom || !enabled) return + const onCopy = (e: ClipboardEvent) => { + const context = buildContext() + if (context) attachSelectionContextToClipboard(e.clipboardData, context) + } + dom.addEventListener('copy', onCopy) + return () => dom.removeEventListener('copy', onCopy) + }, [containerRef, buildContext, enabled]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 233b0e8d202..968257a51a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -12,6 +12,7 @@ import { } from '@sim/emcn/icons' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' +import { fileNameFromSelectionLabel } from '@/lib/copilot/chat/selection-context' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle } from '@/blocks/icon-color' import { getBlockRegistry } from '@/blocks/registry' @@ -75,6 +76,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + table_selection: { + label: 'Table selection', + renderIcon: ({ className }) => , + }, file: { label: 'File', renderIcon: ({ context, className }) => { @@ -82,6 +87,15 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record }, }, + file_selection: { + label: 'File selection', + renderIcon: ({ context, className }) => { + // Strip the `:line` suffix so `getDocumentIcon` reads the real extension + // (e.g. `md`, not `md:12-40`) and shows the correct file glyph. + const FileDocIcon = getDocumentIcon('', fileNameFromSelectionLabel(context.label)) + return + }, + }, folder: { label: 'Folder', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx index b3f9b824970..c2d0a2146b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx @@ -26,8 +26,13 @@ interface ChatSurfaceContextValue { userId?: string /** Notifies the surface owner that a context chip was added to the input. */ onContextAdd: (context: ChatContext) => void - /** Notifies the surface owner that a context chip was removed from the input. */ - onContextRemove: (context: ChatContext) => void + /** + * Notifies the surface owner that a context chip was removed from the input. + * `remaining` is the input's context list AFTER the removal, so the owner can + * tell whether any other chip still references the removed chip's resource + * before closing a shared slideover tab. + */ + onContextRemove: (context: ChatContext, remaining: ChatContext[]) => void /** Opens a workspace resource referenced from rendered message content. */ onWorkspaceResourceSelect: (resource: MothershipResource) => void } @@ -42,7 +47,7 @@ interface ChatSurfaceProviderProps { chatId?: string userId?: string onContextAdd?: (context: ChatContext) => void - onContextRemove?: (context: ChatContext) => void + onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void children: ReactNode } @@ -74,8 +79,8 @@ export function ChatSurfaceProvider({ const stableOnContextAdd = useCallback((context: ChatContext) => { onContextAddRef.current?.(context) }, []) - const stableOnContextRemove = useCallback((context: ChatContext) => { - onContextRemoveRef.current?.(context) + const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => { + onContextRemoveRef.current?.(context, remaining) }, []) const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => { onWorkspaceResourceSelectRef.current?.(resource) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 39abdc7b359..90a78369cab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -160,6 +160,31 @@ export function serializeSelectionForClipboard( return result } +/** + * Finds the selection-scoped chips (`file_selection` / `table_selection`) whose + * highlighted token falls inside `selectedText`. These kinds carry an inline + * text blob / row-id array that can't fit a portable `sim:kind/id` link, so the + * chat input's copy/cut path round-trips them through the custom + * `text/x-sim-selection` clipboard MIME instead. Uses the overlay's exact + * tokenization so a label that is a substring of another never false-matches. + */ +export function selectionContextsInText( + selectedText: string, + contexts: ChatContext[] +): ChatContext[] { + const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts)) + if (ranges.length === 0) return [] + const found: ChatContext[] = [] + for (const range of ranges) { + const label = stripMentionTrigger(range.token) + const matched = contexts.find((c) => c.label === label) + if (matched && (matched.kind === 'file_selection' || matched.kind === 'table_selection')) { + found.push(matched) + } + } + return found +} + /** * Parses all portable chip markdown links from a string, in source order. * diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index a969e39b741..e9893f96902 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -1,9 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + attachSelectionContextToClipboard, + readSelectionContextFromClipboard, +} from '@/lib/copilot/chat/selection-clipboard' import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import { chipDisplayToken, chipLinkToContext, parseChipLinks, + selectionContextsInText, serializeSelectionForClipboard, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' import { @@ -20,6 +25,7 @@ import { useMentionTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import { + isContextAlreadySelected, restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -461,6 +467,40 @@ export function usePromptEditor({ [textareaRef, addContextNotified] ) + /** + * Inserts a context as an `@label` chip at the caret and registers it. Unlike + * the menu-driven inserts, this is triggered programmatically (the + * highlight-to-chat action in the file/table viewers) rather than by a typed + * `@`/`/` trigger, so it always inserts at the current cursor position. + */ + const insertContextChip = useCallback( + (context: ChatContext) => { + // A chip's `@label` token must be unique — `addContext` dedupes a matching + // label, so inserting a token for an already-present context would orphan + // it (a second token with no backing context). Skip and just focus. + if (isContextAlreadySelected(context, contextManagementRef.current.selectedContexts)) { + textareaRef.current?.focus() + return + } + const textarea = textareaRef.current + if (textarea) { + const currentValue = valueRef.current + const insertAt = textarea.selectionStart ?? currentValue.length + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) + const insertText = `${needsSpaceBefore ? ' ' : ''}@${context.label} ` + const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}` + const newPos = insertAt + insertText.length + + pendingCursorRef.current = newPos + valueRef.current = newValue + setValueState(newValue) + } + + addContextNotified(context) + }, + [textareaRef, addContextNotified] + ) + /** * Only reachable via Radix's own dismiss detection (outside click / * Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass @@ -876,6 +916,36 @@ export function usePromptEditor({ const handlePaste = useCallback((e: React.ClipboardEvent) => { const textarea = e.currentTarget + // A selection copied from a file/table (Cmd+C) carries its context on a + // custom clipboard type — paste it as a reference chip instead of plain text. + // Registers via `addContext` (not the notified path) so paste never opens a + // side panel, matching the portable-chip-link paste below. + const selectionContext = readSelectionContextFromClipboard(e.clipboardData) + if (selectionContext) { + e.preventDefault() + // A chip's `@label` token must be unique — `addContext` dedupes a matching + // label, so inserting a token for an already-present selection would orphan + // it (a second token with no backing context). Skip and keep focus, mirroring + // insertContextChip. + if ( + isContextAlreadySelected(selectionContext, contextManagementRef.current.selectedContexts) + ) { + return + } + const selStart = textarea.selectionStart ?? valueRef.current.length + const selEnd = textarea.selectionEnd ?? selStart + const needsSpaceBefore = selStart > 0 && !/\s/.test(valueRef.current.charAt(selStart - 1)) + const insert = `${needsSpaceBefore ? ' ' : ''}@${selectionContext.label} ` + textarea.setRangeText(insert, selStart, selEnd, 'end') + const newValue = textarea.value + const caret = selStart + insert.length + contextManagementRef.current.addContext(selectionContext) + valueRef.current = newValue + setValueState(newValue) + requestAnimationFrame(() => textarea.setSelectionRange(caret, caret)) + return + } + // Portable chip links (`[label](sim:kind/id)`) re-create their chip on // paste-back. Rewrite each link span to its `@label ` token (the trailing // space is REQUIRED so useContextManagement's sync effect doesn't purge the @@ -967,6 +1037,15 @@ export function usePromptEditor({ * text and round-trip by name. Returns true when it took over the clipboard * (the caller must then perform the cut deletion itself, since the default * was prevented). + * + * Selection chips (`file_selection` / `table_selection`) can't fit a portable + * link — their inline text / row-id payload lives only in the context. When + * the selection is exactly one such chip (the common copy/cut of a + * highlight-to-chat chip), ride its full context on the custom + * `text/x-sim-selection` MIME so paste restores it; otherwise it would leave a + * bare `@label` with no backing data. Mixed selections keep the portable/plain + * path (the single-slot MIME can't carry more than one), so the chip degrades + * to its label text there rather than dropping the rest of the selection. */ const writeSanitizedClipboard = useCallback( (e: React.ClipboardEvent): boolean => { @@ -975,10 +1054,18 @@ export function usePromptEditor({ const end = textarea.selectionEnd ?? 0 const selected = textarea.value.slice(start, end) if (!selected) return false - const serialized = serializeSelectionForClipboard( - selected, - contextManagementRef.current.selectedContexts - ) + const contexts = contextManagementRef.current.selectedContexts + const selectionChips = selectionContextsInText(selected, contexts) + const soleSelectionChip = + selectionChips.length === 1 && + selected.replace(chipDisplayToken(selectionChips[0]), '').trim().length === 0 + if (soleSelectionChip) { + e.preventDefault() + e.clipboardData.setData('text/plain', selected) + attachSelectionContextToClipboard(e.clipboardData, selectionChips[0]) + return true + } + const serialized = serializeSelectionForClipboard(selected, contexts) if (serialized === selected) return false e.preventDefault() e.clipboardData.setData('text/plain', serialized) @@ -1023,6 +1110,8 @@ export function usePromptEditor({ clear, focusAtEnd, insertResources, + /** Inserts a context as an `@label` chip at the caret (highlight-to-chat). */ + insertContextChip, insertSlashTrigger, openResourceMenu, /** The editor's textarea element — focus management, caret restore. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 55d73124605..8462c96409c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -15,6 +15,10 @@ import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { + MOTHERSHIP_ADD_CONTEXT_EVENT, + type MothershipAddContextDetail, +} from '@/lib/mothership/events' import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { @@ -172,6 +176,24 @@ const UserInputImpl = forwardRef(function UserI } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore + /** + * Attaches a context chip pushed from elsewhere in the app (the + * highlight-to-chat action in the file/table viewers). `preventDefault` claims + * the event so the producer knows a live input consumed it and skips its + * persist-and-navigate fallback. + */ + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + if (!detail?.context) return + e.preventDefault() + editorRef.current.insertContextChip(detail.context) + textareaRef.current?.focus() + } + window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler) + return () => window.removeEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler) + }, [textareaRef]) + const isFirstSaveRef = useRef(true) useEffect(() => { if (isFirstSaveRef.current) { @@ -223,7 +245,7 @@ const UserInputImpl = forwardRef(function UserI } } const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p))) - if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx)) + if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx, curr)) prevSelectedContextsRef.current = curr }, [editor.contexts]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 868df0fb460..339a0bd3d23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,14 +19,20 @@ import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' +import { + fileNameFromSelectionLabel, + tableNameFromSelectionLabel, +} from '@/lib/copilot/chat/selection-context' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, MothershipHandoffStorage, + MothershipPendingContextStorage, } from '@/lib/core/utils/browser-storage' import { + addMothershipContext, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' @@ -344,6 +350,29 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) if (handoff) sendMessage(handoff.message, undefined, handoff.contexts) }, [chatId, workspaceId, sendMessage]) + /** + * Drains contexts persisted by the highlight-to-chat action (standalone + * Files/Tables page). Runs after this component's mount effects — including + * `useChat`'s chat-init `setResources([])` — so re-dispatching each context + * inserts its chip in the (already mounted) conversation input AND opens its + * resource in the slideover without the reset wiping it. A ref guards against + * the StrictMode double-invoke draining twice. + */ + const hasDrainedPendingContextRef = useRef(false) + useEffect(() => { + if (hasDrainedPendingContextRef.current || !workspaceId) return + hasDrainedPendingContextRef.current = true + const pending = MothershipPendingContextStorage.consume(workspaceId) + for (const context of pending) { + // Open the resource in the slideover directly (deterministic — not + // dependent on the input's event listener being mounted yet), then + // dispatch the event so the mounted input inserts the chip. + handleContextAdd(context) + addMothershipContext(context) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only drain; handleContextAdd is stable enough for a one-shot + }, [workspaceId]) + function resolveResourceFromContext( context: ChatContext ): { type: MothershipResourceType; id: string } | null { @@ -355,24 +384,48 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null case 'table': return context.tableId ? { type: 'table', id: context.tableId } : null + case 'table_selection': + return context.tableId ? { type: 'table', id: context.tableId } : null case 'file': return context.fileId ? { type: 'file', id: context.fileId } : null + case 'file_selection': + return context.fileId ? { type: 'file', id: context.fileId } : null default: return null } } + /** + * Tab title for the resource a chip opens. Selection chips carry a + * location suffix in their label (`notes.md:12-40`, `Sales (3 rows)`); the + * underlying resource is the whole file/table, so strip the suffix. + */ + function resourceTitleForContext(context: ChatContext): string { + if (context.kind === 'file_selection') return fileNameFromSelectionLabel(context.label) + if (context.kind === 'table_selection') return tableNameFromSelectionLabel(context.label) + return context.label + } + function handleContextAdd(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (resolved) { - addResource({ ...resolved, title: context.label }) + addResource({ ...resolved, title: resourceTitleForContext(context) }) handleResourceEvent() } } - function handleInitialContextRemove(context: ChatContext) { + function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) { const resolved = resolveResourceFromContext(context) if (!resolved) return + // A whole-file chip and one or more of its selection chips (or several + // selections of the same file/table) all resolve to the same resource tab. + // Only close the tab once no remaining chip still references it, so removing + // one of several chips doesn't yank a slideover the others still point at. + const stillReferenced = remaining.some((other) => { + const otherResolved = resolveResourceFromContext(other) + return otherResolved?.type === resolved.type && otherResolved.id === resolved.id + }) + if (stillReferenced) return removeResource(resolved.type, resolved.id) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 66a5a47658a..14a5bf9cd16 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -315,8 +315,16 @@ function isChatContext(value: unknown): value is ChatContext { return value.knowledgeId === undefined || typeof value.knowledgeId === 'string' case 'table': return typeof value.tableId === 'string' + case 'table_selection': + return ( + typeof value.tableId === 'string' && + Array.isArray(value.rowIds) && + value.rowIds.every((id) => typeof id === 'string') + ) case 'file': return typeof value.fileId === 'string' + case 'file_selection': + return typeof value.fileId === 'string' && typeof value.text === 'string' case 'folder': return typeof value.folderId === 'string' case 'filefolder': @@ -3221,6 +3229,19 @@ export function useChat( ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), + ...(c.kind === 'file_selection' + ? { + text: c.text, + ...(c.startLine ? { startLine: c.startLine } : {}), + ...(c.endLine ? { endLine: c.endLine } : {}), + } + : {}), + ...(c.kind === 'table_selection' + ? { + rowIds: c.rowIds, + ...(c.columnIds ? { columnIds: c.columnIds } : {}), + } + : {}), })) const cachedUserMsg: PersistedMessage = { id: userMessageId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 4759bb9d1d6..b06b0cd437c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -148,6 +148,15 @@ export interface ChatMessageContext { blockType?: string skillId?: string serverId?: string + /** Selected passage for a `file_selection` context. */ + text?: string + /** 1-based inclusive line range for a `file_selection` context. */ + startLine?: number + endLine?: number + /** Selected row ids for a `table_selection` context. */ + rowIds?: string[] + /** Selected column ids for a `table_selection` cell range. */ + columnIds?: string[] } export interface ChatMessage { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 34d6561b689..8dc84f53592 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -8,6 +8,7 @@ import { import { ArrowDown, ArrowUp, + Blimp, Duplicate, Eye, Pencil, @@ -55,6 +56,10 @@ interface ContextMenuProps { */ disableDuplicate?: boolean disableDelete?: boolean + /** Adds the selected rows / cell range to Chat as a reference. Omit to hide. */ + onAddToChat?: () => void + /** Label describing the current selection scope, e.g. "3 rows" or "cell range". */ + addToChatLabel?: string } export function ContextMenu({ @@ -79,6 +84,8 @@ export function ContextMenu({ disableInsert = false, disableDuplicate = false, disableDelete = false, + onAddToChat, + addToChatLabel = 'Add to chat', }: ContextMenuProps) { const count = selectedRowCount.toLocaleString() const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' @@ -127,6 +134,15 @@ export function ContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {onAddToChat && ( + <> + + + {addToChatLabel} + + + + )} {contextMenu.columnName && canEditCell && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index ed93cdaca87..2908af97268 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -10,6 +10,13 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' +import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import { + buildTableSelectionLabel, + MAX_TABLE_SELECTION_COLUMNS, + MAX_TABLE_SELECTION_ROWS, + selectionKey, +} from '@/lib/copilot/chat/selection-context' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, @@ -38,6 +45,7 @@ import { useUpdateTableRow, useUpdateWorkflowGroup, } from '@/hooks/queries/tables' +import { useAddToChat } from '@/hooks/use-add-to-chat' import { useInlineRename } from '@/hooks/use-inline-rename' import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' import type { DeletedRowSnapshot } from '@/stores/table/types' @@ -961,6 +969,9 @@ export function TableGrid({ const rowSelectionRef = useRef(rowSelection) rowSelectionRef.current = rowSelection + const tableNameRef = useRef(tableData?.name) + tableNameRef.current = tableData?.name + columnsRef.current = displayColumns schemaColumnsRef.current = columns workflowGroupsRef.current = tableWorkflowGroups @@ -2854,6 +2865,45 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() + // The async Clipboard API (`navigator.clipboard.write`) used by the paged + // path below replaces the whole clipboard and can't carry a custom MIME + // type. For an explicit multi-row ('some') selection whose rows are all + // loaded, do a SYNCHRONOUS event write instead so the chat-selection chip + // rides alongside `text/plain` (same as the cell-range path). 'all' + // (filtered select-all) and oversized selections fall through to the + // paged plain-text write, which can't carry the chip. + if (rowSel.kind === 'some') { + const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id)) + const allLoaded = selectedRows.length === rowSel.ids.size + if ( + allLoaded && + selectedRows.length > 0 && + selectedRows.length <= MAX_TABLE_SELECTION_ROWS + ) { + const text = selectedRows + .map((row) => cols.map((col) => cellToText(row.data[col.key], col)).join('\t')) + .join('\n') + e.clipboardData?.setData('text/plain', text) + if (tableNameRef.current) { + const rowIds = selectedRows.map((row) => row.id) + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rowIds.length, + undefined, + selectionKey(rowIds) + ), + rowIds, + }) + } + toast.success( + `Copied ${selectedRows.length} ${selectedRows.length === 1 ? 'row' : 'rows'}` + ) + return + } + } writeSelectionToClipboard({ loadRows: rowSel.kind === 'all' @@ -2893,6 +2943,42 @@ export function TableGrid({ return } + // Ride a table_selection (bounded rows + the range's columns) onto the + // clipboard so pasting this cell range into Chat yields the same chip. + if (tableNameRef.current) { + const rangeRowIds: string[] = [] + for ( + let r = sel.startRow; + r <= sel.endRow && rangeRowIds.length < MAX_TABLE_SELECTION_ROWS; + r++ + ) { + const row = currentRows[r] + if (row) rangeRowIds.push(row.id) + } + const rangeColumnIds: string[] = [] + for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { + rangeColumnIds.push(getColumnId(cols[c])) + } + const columnIds = + rangeColumnIds.length > 0 && rangeColumnIds.length < cols.length + ? rangeColumnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) + : undefined + if (rangeRowIds.length > 0) { + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rangeRowIds.length, + columnIds?.length, + selectionKey([...rangeRowIds, ...(columnIds ?? [])]) + ), + rowIds: rangeRowIds, + ...(columnIds ? { columnIds } : {}), + }) + } + } + const lines: string[] = [] for (let r = sel.startRow; r <= sel.endRow; r++) { const cells: string[] = [] @@ -3610,6 +3696,80 @@ export function TableGrid({ ) : contextMenuRowIds.length || 1 + /** + * Column ids for an "Add to chat" table selection. A spreadsheet-style cell + * range AND a column-header selection (which spans every row of the chosen + * columns) narrow the columns; whole-row (gutter) selections and single rows + * send every column (undefined). A range spanning all columns is equivalent to + * whole rows, so it also collapses to undefined. + */ + const contextMenuColumnIds = useMemo(() => { + if (!contextMenu.isOpen || !contextMenu.row) return undefined + if ( + !rowSelectionIsEmpty(rowSelection) && + rowSelectionIncludes(rowSelection, contextMenu.row.id) + ) { + return undefined + } + const sel = normalizedSelection + if (!sel) return undefined + const contextRowArrayIndex = rows.findIndex((r) => r.id === contextMenu.row!.id) + if (contextRowArrayIndex < sel.startRow || contextRowArrayIndex > sel.endRow) return undefined + const ids: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + const col = displayColumns[c] + if (col) ids.push(getColumnId(col)) + } + return ids.length > 0 && ids.length < displayColumns.length ? ids : undefined + }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows, displayColumns]) + + const addToChat = useAddToChat() + const handleAddSelectionToChat = useCallback(async () => { + // A gutter select-all (filtered) or a column-header selection (every row of + // the chosen columns) covers rows beyond the loaded page that + // `contextMenuRowIds` reflects; drain up to the cap so the chip references as + // many rows as it can carry (bounded by MAX_TABLE_SELECTION_ROWS) instead of a + // silent loaded-only subset — mirroring how the copy path loads before writing. + let sourceRowIds = contextMenuRowIds + if (contextMenuIsSelectAll || isColumnSelectionRef.current) { + try { + const { rows: loaded } = await ensureRowsLoadedUpToRef.current(MAX_TABLE_SELECTION_ROWS) + // A column selection spans all rows; a gutter select-all filters by the + // (exclusion-aware) row selection. + const drained = ( + contextMenuIsSelectAll + ? loaded.filter((row) => rowSelectionIncludes(rowSelectionRef.current, row.id)) + : loaded + ).map((row) => row.id) + if (drained.length > 0) sourceRowIds = drained + } catch { + // Fall back to the already-loaded rows if the drain fails. + } + } + if (sourceRowIds.length === 0) return + const rowIds = sourceRowIds.slice(0, MAX_TABLE_SELECTION_ROWS) + const columnIds = contextMenuColumnIds?.slice(0, MAX_TABLE_SELECTION_COLUMNS) + addToChat({ + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableData?.name ?? 'Table', + rowIds.length, + columnIds?.length, + selectionKey([...rowIds, ...(columnIds ?? [])]) + ), + rowIds, + ...(columnIds && columnIds.length > 0 ? { columnIds } : {}), + }) + }, [ + addToChat, + contextMenuRowIds, + contextMenuColumnIds, + contextMenuIsSelectAll, + tableId, + tableData?.name, + ]) + const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null /** @@ -4323,6 +4483,8 @@ export function TableGrid({ disableInsert={!canManualAddRow} disableDuplicate={!canInsertFullRow} disableDelete={!canDeleteRow} + onAddToChat={contextMenuRowIds.length > 0 ? handleAddSelectionToChat : undefined} + addToChatLabel={contextMenuColumnIds ? 'Add cell range to chat' : 'Add rows to chat'} /> void { + const { workspaceId } = useParams<{ workspaceId: string }>() + + return useCallback( + (context: ChatContext) => { + const consumed = addMothershipContext(context) + if (consumed) return + if (!workspaceId) return + MothershipPendingContextStorage.store(context, workspaceId) + // Hard navigation (not router.push): a full Chat mount reliably opens the + // resource in the slideover. A client-side transition races with useChat's + // resource-reset effect and drops the just-opened resource. + window.location.assign(`/workspace/${workspaceId}/home`) + }, + [workspaceId] + ) +} diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 96878919b87..c6c2bc99a4b 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -22,6 +22,11 @@ import { processContextsServer, resolveActiveResourceContext, } from '@/lib/copilot/chat/process-contents' +import { + MAX_FILE_SELECTION_TEXT_LENGTH, + MAX_TABLE_SELECTION_COLUMNS, + MAX_TABLE_SELECTION_ROWS, +} from '@/lib/copilot/chat/selection-context' import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' @@ -145,7 +150,9 @@ const ChatContextSchema = z.object({ 'knowledge', 'docs', 'table', + 'table_selection', 'file', + 'file_selection', 'folder', 'filefolder', 'scheduledtask', @@ -171,6 +178,11 @@ const ChatContextSchema = z.object({ scheduleId: z.string().optional(), tabId: z.string().optional(), terminalId: z.string().optional(), + text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(), + startLine: z.number().int().positive().optional(), + endLine: z.number().int().positive().optional(), + rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(), + columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(), }) const ChatMessageSchema = z.object({ diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index bb2172e71f3..4f6f7972272 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -6,13 +6,20 @@ import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' -const { discoverServerTools, getSkillById } = vi.hoisted(() => ({ - discoverServerTools: vi.fn(), - getSkillById: vi.fn(), -})) +const { discoverServerTools, getSkillById, getWorkspaceFile, getTableById, getRowsByIds } = + vi.hoisted(() => ({ + discoverServerTools: vi.fn(), + getSkillById: vi.fn(), + getWorkspaceFile: vi.fn(), + getTableById: vi.fn(), + getRowsByIds: vi.fn(), + })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile })) +vi.mock('@/lib/table/service', () => ({ getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) /** * Overrides the global `@sim/db` mock: the logs-context tests below need @@ -294,3 +301,160 @@ describe('processContextsServer - logs contexts', () => { expect(result).toEqual([]) }) }) + +describe('processContextsServer - file_selection contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('inlines the selected passage with its line range and a path pointer', async () => { + getWorkspaceFile.mockResolvedValue({ name: 'notes.md', folderPath: null }) + + const result = await processContextsServer( + [ + { + kind: 'file_selection', + fileId: 'file-1', + label: 'notes.md:12-14', + text: 'the exact passage', + startLine: 12, + endLine: 14, + } as ChatContext, + ], + 'user-1', + 'explain this', + 'ws-1' + ) + + expect(getWorkspaceFile).toHaveBeenCalledWith('ws-1', 'file-1') + expect(result).toHaveLength(1) + const [ctx] = result + expect(ctx.type).toBe('file_selection') + expect(ctx.tag).toBe('@notes.md:12-14') + expect(ctx.content).toContain('lines 12-14') + expect(ctx.content).toContain('the exact passage') + expect(ctx.path).toBeTruthy() + }) + + it('drops the selection when the file does not resolve', async () => { + getWorkspaceFile.mockResolvedValue(null) + + const result = await processContextsServer( + [ + { + kind: 'file_selection', + fileId: 'missing', + label: 'x', + text: 'anything', + } as ChatContext, + ], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) + +describe('processContextsServer - table_selection contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('re-fetches rows by id and renders a markdown table for the selected columns', async () => { + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { + columns: [ + { id: 'c_name', name: 'Name' }, + { id: 'c_amount', name: 'Amount' }, + { id: 'c_notes', name: 'Notes' }, + ], + }, + }) + getRowsByIds.mockResolvedValue([ + { id: 'r1', data: { c_name: 'Acme', c_amount: 100, c_notes: 'ignored' } }, + { id: 'r2', data: { c_name: 'Globex', c_amount: 250, c_notes: 'ignored' } }, + ]) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + label: 'Sales (2 rows, 2 cols)', + rowIds: ['r1', 'r2'], + columnIds: ['c_name', 'c_amount'], + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + expect(getRowsByIds).toHaveBeenCalledWith('tbl-1', ['r1', 'r2'], 'ws-1') + expect(result).toHaveLength(1) + const [ctx] = result + expect(ctx.type).toBe('table_selection') + expect(ctx.content).toContain('| Name | Amount |') + expect(ctx.content).toContain('| Acme | 100 |') + expect(ctx.content).toContain('| Globex | 250 |') + // Unselected column is excluded from the cell range. + expect(ctx.content).not.toContain('Notes') + expect(ctx.content).not.toContain('ignored') + }) + + it('drops the selection for a cross-workspace table', async () => { + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'other-ws', + schema: { columns: [] }, + }) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + label: 'x', + rowIds: ['r1'], + } as ChatContext, + ], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(getRowsByIds).not.toHaveBeenCalled() + expect(result).toEqual([]) + }) + + it('drops a cell range whose columns no longer resolve (never expands to full table)', async () => { + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'c_name', name: 'Name' }] }, + }) + getRowsByIds.mockResolvedValue([{ id: 'r1', data: { c_name: 'Acme' } }]) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + label: 'Sales (1 row, 1 col)', + rowIds: ['r1'], + // Column was renamed/deleted since the selection was captured. + columnIds: ['c_deleted'], + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 1d5096fa84d..08565d33306 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { truncateSelectionText } from '@/lib/copilot/chat/selection-context' import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { @@ -23,7 +24,10 @@ import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' +import { getColumnId } from '@/lib/table/column-keys' +import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' +import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getSkillById } from '@/lib/workflows/skills/operations' @@ -41,7 +45,9 @@ type AgentContextType = | 'logs' | 'knowledge' | 'table' + | 'table_selection' | 'file' + | 'file_selection' | 'workflow_block' | 'docs' | 'folder' @@ -190,6 +196,31 @@ export async function processContextsServer( path: result.path, } } + if (ctx.kind === 'file_selection' && ctx.fileId && currentWorkspaceId) { + return await resolveFileSelectionResource( + ctx.fileId, + currentWorkspaceId, + ctx.text ?? '', + ctx.label, + ctx.startLine, + ctx.endLine + ) + } + if ( + ctx.kind === 'table_selection' && + ctx.tableId && + Array.isArray(ctx.rowIds) && + ctx.rowIds.length > 0 && + currentWorkspaceId + ) { + return await resolveTableSelectionResource( + ctx.tableId, + currentWorkspaceId, + ctx.rowIds, + ctx.columnIds, + ctx.label + ) + } if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) { const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId) if (!result) return null @@ -847,6 +878,96 @@ async function resolveFileResource( } } +/** + * Resolves a highlighted passage from a file into an inline, citable snippet. + * The selected text travels with the request (it is the user's own content), so + * the agent sees the exact bytes without re-reading; the canonical VFS path is + * still attached so the agent can open the full file for surrounding context. + */ +async function resolveFileSelectionResource( + fileId: string, + workspaceId: string, + text: string, + label: string, + startLine?: number, + endLine?: number +): Promise { + const record = await getWorkspaceFile(workspaceId, fileId) + if (!record) return null + const path = canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) + const snippet = truncateSelectionText(text) + const lineRange = + startLine && endLine && endLine !== startLine + ? ` (lines ${startLine}-${endLine})` + : startLine + ? ` (line ${startLine})` + : '' + const content = `Selected passage from ${record.name}${lineRange}:\n\n\`\`\`\n${snippet}\n\`\`\`` + return { + type: 'file_selection', + tag: label ? `@${label}` : '@', + content, + path, + } +} + +/** + * Resolves a table selection into an inline markdown table. Rows are re-fetched + * by id from the DB (never trusting client-sent cell values); when `columnIds` + * is present the projection is narrowed to that cell range, otherwise every + * column is included. + */ +async function resolveTableSelectionResource( + tableId: string, + workspaceId: string, + rowIds: string[], + columnIds: string[] | undefined, + label: string +): Promise { + const table = await getTableById(tableId) + if (!table || table.workspaceId !== workspaceId) return null + + const rows = await getRowsByIds(tableId, rowIds, workspaceId) + if (rows.length === 0) return null + + const allColumns: ColumnDefinition[] = table.schema?.columns ?? [] + // A cell range (`columnIds` present) narrows to those columns; whole-row + // selections use every column. If a cell range's columns no longer resolve + // (schema changed since the selection was made), keep the range scope empty + // and drop the resource — never silently expand a narrow selection into a + // full-table dump. + const hasColumnScope = Boolean(columnIds && columnIds.length > 0) + const columns = hasColumnScope + ? allColumns.filter((col) => columnIds?.includes(getColumnId(col))) + : allColumns + if (columns.length === 0) return null + + const header = `| ${columns.map((c) => c.name).join(' | ')} |` + const divider = `| ${columns.map(() => '---').join(' | ')} |` + const body = rows + .map((row) => { + const cells = columns.map((col) => { + const value = row.data[getColumnId(col)] + if (value === null || value === undefined) return '' + const cell = typeof value === 'string' ? value : JSON.stringify(value) + return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ') + }) + return `| ${cells.join(' | ')} |` + }) + .join('\n') + + const scope = columnIds && columnIds.length > 0 ? 'cell range' : 'rows' + const content = `Selected ${scope} from table "${table.name}" (${rows.length} ${ + rows.length === 1 ? 'row' : 'rows' + }):\n\n${header}\n${divider}\n${body}` + return { + type: 'table_selection', + tag: label ? `@${label}` : '@', + content, + path: canonicalTableVfsPath(table.name), + } +} + async function resolveFileFolderResource( folderId: string, workspaceId: string diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.test.ts b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts new file mode 100644 index 00000000000..4ba32165732 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ChatContext } from '@/stores/panel' +import { + attachSelectionContextToClipboard, + readSelectionContextFromClipboard, + SIM_SELECTION_MIME, +} from './selection-clipboard' + +/** Minimal DataTransfer stand-in (jsdom-free node env). */ +function fakeClipboard(initial: Record = {}) { + const store: Record = { ...initial } + return { + setData: (type: string, value: string) => { + store[type] = value + }, + getData: (type: string) => store[type] ?? '', + } as unknown as DataTransfer +} + +const fileSelection: ChatContext = { + kind: 'file_selection', + fileId: 'wf_1', + label: 'notes.md:2-4', + text: 'the exact passage', + startLine: 2, + endLine: 4, +} + +const tableSelection: ChatContext = { + kind: 'table_selection', + tableId: 'tbl_1', + label: 'Sales (2 rows)', + rowIds: ['r1', 'r2'], +} + +describe('selection clipboard codec', () => { + it('round-trips a file selection through the custom MIME type', () => { + const dt = fakeClipboard() + attachSelectionContextToClipboard(dt, fileSelection) + expect(dt.getData(SIM_SELECTION_MIME)).toContain('file_selection') + expect(readSelectionContextFromClipboard(dt)).toEqual(fileSelection) + }) + + it('round-trips a table selection', () => { + const dt = fakeClipboard() + attachSelectionContextToClipboard(dt, tableSelection) + expect(readSelectionContextFromClipboard(dt)).toEqual(tableSelection) + }) + + it('does not touch text/plain (rides alongside it)', () => { + const dt = fakeClipboard({ 'text/plain': 'the exact passage' }) + attachSelectionContextToClipboard(dt, fileSelection) + expect(dt.getData('text/plain')).toBe('the exact passage') + }) + + it('returns null when the custom type is absent (plain paste)', () => { + expect(readSelectionContextFromClipboard(fakeClipboard({ 'text/plain': 'hi' }))).toBeNull() + }) + + it('returns null on malformed JSON', () => { + expect( + readSelectionContextFromClipboard(fakeClipboard({ [SIM_SELECTION_MIME]: '{not json' })) + ).toBeNull() + }) + + it('rejects a file selection missing its text', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file_selection', fileId: 'wf_1', label: 'x' }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) + + it('rejects a table selection with no rows', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ + kind: 'table_selection', + tableId: 'tbl_1', + label: 'x', + rowIds: [], + }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) + + it('rejects an unrelated context kind', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file', fileId: 'wf_1', label: 'x' }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.ts b/apps/sim/lib/copilot/chat/selection-clipboard.ts new file mode 100644 index 00000000000..9ee48706ad8 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.ts @@ -0,0 +1,59 @@ +import type { ChatContext } from '@/stores/panel' + +/** + * Custom clipboard MIME type carrying a selection {@link ChatContext} so a + * highlighted passage copied from a file/table can be pasted into the Chat input + * as a reference chip. Written alongside `text/plain` (never replacing it), so + * pasting anywhere else still yields the plain selection text. + */ +export const SIM_SELECTION_MIME = 'text/x-sim-selection' + +/** + * Attaches a selection context to a copy event's clipboard. Adds the custom MIME + * type WITHOUT calling `preventDefault`, so the editor's own copy handler (Monaco, + * ProseMirror) still writes `text/plain`/`text/html` — the custom type simply + * rides along on the shared `DataTransfer`. + */ +export function attachSelectionContextToClipboard( + clipboardData: DataTransfer | null, + context: ChatContext +): void { + if (!clipboardData) return + try { + clipboardData.setData(SIM_SELECTION_MIME, JSON.stringify(context)) + } catch { + // Some browsers reject custom types mid-gesture; degrade to plain-text copy. + } +} + +/** + * Reads a selection context previously written by + * {@link attachSelectionContextToClipboard}, or null when the clipboard carries + * no (or an invalid) selection payload. + */ +export function readSelectionContextFromClipboard( + clipboardData: DataTransfer | null +): ChatContext | null { + const raw = clipboardData?.getData(SIM_SELECTION_MIME) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as ChatContext + if (!parsed || typeof parsed.label !== 'string') return null + // Require each kind's resolving field so a chip never pastes only to + // resolve to nothing server-side. + if (parsed.kind === 'file_selection' && typeof parsed.text === 'string' && parsed.fileId) { + return parsed + } + if ( + parsed.kind === 'table_selection' && + parsed.tableId && + Array.isArray(parsed.rowIds) && + parsed.rowIds.length > 0 + ) { + return parsed + } + } catch { + // Malformed payload — fall back to plain-text paste. + } + return null +} diff --git a/apps/sim/lib/copilot/chat/selection-context.test.ts b/apps/sim/lib/copilot/chat/selection-context.test.ts new file mode 100644 index 00000000000..ee62399dbc0 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildFileSelectionLabel, + buildTableSelectionLabel, + fileNameFromSelectionLabel, + selectionKey, + tableNameFromSelectionLabel, +} from './selection-context' + +describe('buildFileSelectionLabel', () => { + it('renders a line range', () => { + expect(buildFileSelectionLabel('notes.md', 12, 40)).toBe('notes.md:12-40') + }) + + it('renders a single line when start equals end', () => { + expect(buildFileSelectionLabel('notes.md', 12, 12)).toBe('notes.md:12') + }) + + it('renders a single line when only start is known', () => { + expect(buildFileSelectionLabel('notes.md', 12)).toBe('notes.md:12') + }) + + it('falls back to just the file name with no line info', () => { + expect(buildFileSelectionLabel('notes.md')).toBe('notes.md') + }) + + it('appends the disambiguation key when provided', () => { + expect(buildFileSelectionLabel('notes.md', 12, 40, 'k3f9')).toBe('notes.md:12-40 #k3f9') + expect(buildFileSelectionLabel('notes.md', undefined, undefined, 'k3f9')).toBe('notes.md #k3f9') + }) +}) + +describe('buildTableSelectionLabel', () => { + it('pluralizes rows and omits columns for whole-row selections', () => { + expect(buildTableSelectionLabel('Sales', 5)).toBe('Sales (5 rows)') + expect(buildTableSelectionLabel('Sales', 1)).toBe('Sales (1 row)') + }) + + it('includes column count for a cell range', () => { + expect(buildTableSelectionLabel('Sales', 5, 3)).toBe('Sales (5 rows, 3 cols)') + expect(buildTableSelectionLabel('Sales', 2, 1)).toBe('Sales (2 rows, 1 col)') + }) + + it('appends the disambiguation key when provided', () => { + expect(buildTableSelectionLabel('Sales', 2, undefined, 'k3f9')).toBe('Sales (2 rows #k3f9)') + expect(buildTableSelectionLabel('Sales', 2, 3, 'k3f9')).toBe('Sales (2 rows, 3 cols #k3f9)') + }) +}) + +describe('selectionKey', () => { + it('is deterministic and order-independent', () => { + expect(selectionKey(['r1', 'r2', 'r3'])).toBe(selectionKey(['r3', 'r1', 'r2'])) + }) + + it('differs for distinct id sets of the same size', () => { + expect(selectionKey(['r1', 'r2'])).not.toBe(selectionKey(['r3', 'r4'])) + }) +}) + +describe('selection-label name recovery', () => { + it('strips the line suffix from a file selection label', () => { + expect(fileNameFromSelectionLabel('notes.md:12-40')).toBe('notes.md') + expect(fileNameFromSelectionLabel('notes.md')).toBe('notes.md') + }) + + it('strips the disambiguation key and line suffix from a file selection label', () => { + expect(fileNameFromSelectionLabel('notes.md:12-40 #k3f9')).toBe('notes.md') + expect(fileNameFromSelectionLabel('notes.md #k3f9')).toBe('notes.md') + }) + + it('strips rows/cols/key suffix from a table selection label', () => { + expect(tableNameFromSelectionLabel('Sales (2 rows)')).toBe('Sales') + expect(tableNameFromSelectionLabel('Sales (2 rows, 3 cols #k3f9)')).toBe('Sales') + expect(tableNameFromSelectionLabel('Sales (5 rows #ab12)')).toBe('Sales') + }) +}) diff --git a/apps/sim/lib/copilot/chat/selection-context.ts b/apps/sim/lib/copilot/chat/selection-context.ts new file mode 100644 index 00000000000..8f7e7e0422b --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.ts @@ -0,0 +1,109 @@ +/** + * Shared bounds and label helpers for selection-scoped chat contexts + * (`file_selection`, `table_selection`). Kept free of server-only imports so + * both the client producers (file/table viewers) and the server validator / + * resolver can consume the same limits and formatting. + */ + +import { truncate } from '@sim/utils/string' + +/** + * Max characters of selected file text carried inline on a `file_selection`. + * This is the ceiling on the FINAL serialized string, matched by the server + * schema's `.max(...)`; always truncate through {@link truncateSelectionText} + * so the trailing ellipsis can't push the value past this bound. + */ +export const MAX_FILE_SELECTION_TEXT_LENGTH = 20_000 + +/** Max rows referenced by a single `table_selection`. */ +export const MAX_TABLE_SELECTION_ROWS = 500 + +/** Max columns referenced by a `table_selection` cell range. */ +export const MAX_TABLE_SELECTION_COLUMNS = 200 + +/** Length of the ellipsis {@link truncate} appends when it shortens a string. */ +const TRUNCATE_SUFFIX_LENGTH = 3 + +/** + * Truncates selected file text so the RESULT (including the appended ellipsis) + * never exceeds {@link MAX_FILE_SELECTION_TEXT_LENGTH} — keeping the client + * payload within the server schema bound, which otherwise rejects the whole + * chat request. + */ +export function truncateSelectionText(text: string): string { + return truncate(text, MAX_FILE_SELECTION_TEXT_LENGTH - TRUNCATE_SUFFIX_LENGTH) +} + +/** + * Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40`, + * `notes.md:12`, or just `notes.md` when no line range is known. An optional + * trailing `key` (from {@link selectionKey}) disambiguates two distinct passages + * that share the same line range — without it their labels collide and the + * second chip is dropped (chips are keyed by their `@label`). Kept ASCII so the + * label survives being inserted as an inline mention token in the chat input. + */ +export function buildFileSelectionLabel( + fileName: string, + startLine?: number, + endLine?: number, + key?: string +): string { + const suffix = key ? ` #${key}` : '' + if (!startLine) return `${fileName}${suffix}` + const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}` + return `${fileName}:${range}${suffix}` +} + +/** + * Short, deterministic key for a set of ids — same ids (any order) yield the + * same key. Used to disambiguate table-selection labels so two distinct + * selections of the same size don't collapse to one chip (chips are keyed by + * their `@label`, and a collision would drop the second context). + */ +export function selectionKey(ids: string[]): string { + const joined = [...ids].sort().join(',') + let hash = 0 + for (let i = 0; i < joined.length; i++) { + hash = (Math.imul(hash, 31) + joined.charCodeAt(i)) | 0 + } + return (hash >>> 0).toString(36) +} + +/** + * Builds the chip label for a table selection, e.g. `Sales (5 rows #k3f9)` or, + * for a cell range, `Sales (5 rows, 3 cols #k3f9)`. The trailing `key` (from + * {@link selectionKey}) keeps distinct same-size selections from sharing a + * label. ASCII-only for the same reason as {@link buildFileSelectionLabel}. + */ +export function buildTableSelectionLabel( + tableName: string, + rowCount: number, + columnCount?: number, + key?: string +): string { + const rows = `${rowCount} ${rowCount === 1 ? 'row' : 'rows'}` + const suffix = key ? ` #${key}` : '' + if (!columnCount) return `${tableName} (${rows}${suffix})` + const cols = `${columnCount} ${columnCount === 1 ? 'col' : 'cols'}` + return `${tableName} (${rows}, ${cols}${suffix})` +} + +/** + * Recovers the bare file name from a {@link buildFileSelectionLabel} label by + * stripping the trailing ` #key` disambiguator (when present) and the + * `:line` / `:start-end` range. Co-located with the builder so the two formats + * can't drift apart. Used to title the resource tab (the whole file) rather than + * the selection. + */ +export function fileNameFromSelectionLabel(label: string): string { + return label.replace(/ #[0-9a-z]+$/, '').replace(/:\d+(?:-\d+)?$/, '') +} + +/** + * Recovers the bare table name from a {@link buildTableSelectionLabel} label by + * stripping the trailing ` (N rows[, M cols])` suffix. Co-located with the + * builder for the same reason as {@link fileNameFromSelectionLabel}. + */ +export function tableNameFromSelectionLabel(label: string): string { + return label.replace(/\s*\(\d+ rows?(?:, \d+ cols?)?(?: #[0-9a-z]+)?\)$/, '') +} diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index f5bb0cc483d..981036748c5 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -105,6 +105,7 @@ export const STORAGE_KEYS = { LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed', WORKSPACE_RECENCY: 'sim_workspace_recency', MOTHERSHIP_HANDOFF: 'sim_mothership_handoff', + MOTHERSHIP_PENDING_CONTEXTS: 'sim_mothership_pending_contexts', } as const export class WorkspaceRecencyStorage { @@ -380,3 +381,59 @@ export class MothershipHandoffStorage { return BrowserStorage.removeItem(MothershipHandoffStorage.KEY) } } + +interface PendingContextsEntry { + contexts: ChatContext[] + workspaceId: string + timestamp: number +} + +/** + * Accumulates context chips to seed into the Chat input when it next mounts, + * used by the highlight-to-chat action when no chat is currently listening + * (e.g. the user is on the standalone Files/Tables page). Unlike + * {@link MothershipHandoffStorage}, this never auto-sends — it only pre-fills + * the input with reference chips, matching the passive "add to chat" behavior. + * Multiple adds accumulate; `consume` drains and clears the list. + */ +export class MothershipPendingContextStorage { + private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_PENDING_CONTEXTS + + /** Appends a context for the workspace, replacing any entry from another workspace. */ + static store(context: ChatContext, workspaceId: string): boolean { + if (!workspaceId) return false + const existing = BrowserStorage.getItem( + MothershipPendingContextStorage.KEY, + null + ) + const contexts = + existing && existing.workspaceId === workspaceId ? [...existing.contexts, context] : [context] + return BrowserStorage.setItem(MothershipPendingContextStorage.KEY, { + contexts, + workspaceId, + timestamp: Date.now(), + }) + } + + /** + * Retrieves and clears the pending contexts for `workspaceId`. Entries owned + * by another workspace are left untouched; stale entries past `maxAge` are + * dropped. + * @param maxAge - Maximum age in milliseconds (default: 5 minutes) + */ + static consume(workspaceId: string, maxAge: number = 5 * 60 * 1000): ChatContext[] { + const data = BrowserStorage.getItem( + MothershipPendingContextStorage.KEY, + null + ) + if (!data) return [] + if (data.workspaceId !== workspaceId) return [] + MothershipPendingContextStorage.clear() + if (!data.timestamp || Date.now() - data.timestamp > maxAge) return [] + return Array.isArray(data.contexts) ? data.contexts : [] + } + + static clear(): boolean { + return BrowserStorage.removeItem(MothershipPendingContextStorage.KEY) + } +} diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 9143a73c5b5..50b13070028 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -45,3 +45,36 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) }) return consumed } + +/** + * Custom-event name used to attach a context chip to the Mothership chat input + * WITHOUT sending a message. The mounted chat input listens for this and inserts + * the chip, leaving the user to type their prompt and send when ready. + */ +export const MOTHERSHIP_ADD_CONTEXT_EVENT = 'mothership-add-context' + +export interface MothershipAddContextDetail { + /** The context to attach as a chip in the input. */ + context: ChatContext +} + +/** + * Dispatches a passive "add this context chip" request to a mounted Mothership + * chat input. Producers (the highlight-to-chat action in the file/table viewers) + * call this; the mounted input listens for {@link MOTHERSHIP_ADD_CONTEXT_EVENT} + * and `preventDefault`s to claim it. + * + * @returns `true` when a mounted input consumed it, `false` when none was + * listening — callers fall back to persisting the context for the next chat + * mount (see `MothershipPendingContextStorage`). + */ +export function addMothershipContext(context: ChatContext): boolean { + const consumed = !window.dispatchEvent( + new CustomEvent(MOTHERSHIP_ADD_CONTEXT_EVENT, { + detail: { context }, + cancelable: true, + }) + ) + logger.info('Dispatched mothership add-context event', { kind: context.kind, consumed }) + return consumed +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 41eedd066f8..1c5d998e5a5 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1150,6 +1150,36 @@ export async function getRowById( } } +/** + * Fetches the `data` payloads for a set of rows by id, scoped to a table and + * workspace. Returns lightweight `{ id, data }` records (no executions) in the + * order the ids were requested, silently skipping ids that don't resolve. Used + * to materialize a `table_selection` chat context server-side so the agent gets + * fresh, authoritative cell values instead of trusting client-sent copies. + */ +export async function getRowsByIds( + tableId: string, + rowIds: string[], + workspaceId: string +): Promise> { + const uniqueIds = Array.from(new Set(rowIds)) + if (uniqueIds.length === 0) return [] + + const results = await db + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + inArray(userTableRows.id, uniqueIds), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + + const byId = new Map(results.map((r) => [r.id, r.data as RowData])) + return uniqueIds.filter((id) => byId.has(id)).map((id) => ({ id, data: byId.get(id) as RowData })) +} + /** Internal: thrown inside `db.transaction` to roll back when the executions * guard rejects a write. The outer `.catch` translates it into a `null` return. */ class GuardRejected extends Error { diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index dbf91a502a4..815ad7a06a6 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -24,7 +24,29 @@ export type ChatContext = | { kind: 'workflow_block'; workflowId: string; blockId: string; label: string } | { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'table'; tableId: string; label: string } + | { + kind: 'table_selection' + tableId: string + label: string + /** Ids of the selected rows. Always present (materialized from the grid selection). */ + rowIds: string[] + /** + * Ids of the selected columns. Present only for a spreadsheet-style cell + * range; absent when whole rows are selected. + */ + columnIds?: string[] + } | { kind: 'file'; fileId: string; label: string } + | { + kind: 'file_selection' + fileId: string + label: string + /** The literal selected text, carried inline so the agent sees the exact passage. */ + text: string + /** 1-based inclusive line range of the selection, when the source has lines. */ + startLine?: number + endLine?: number + } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } | { kind: 'scheduledtask'; scheduleId: string; label: string } From 3dac5a865b1855a8cabc74ba8742661fef7b582f Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 29 Jul 2026 21:49:31 -0700 Subject: [PATCH 02/26] fix(chat): widen selection code fence so embedded backticks can't truncate it Cursor: resolveFileSelectionResource wrapped the selected passage in a fixed ``` fence, so a selection that itself contained a fenced code block closed the outer fence early and the agent received a truncated snippet. The fence is now one backtick longer than the longest backtick run in the content (floored at three), matching CommonMark's close rule. --- .../lib/copilot/chat/process-contents.test.ts | 29 +++++++++++++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 18 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 4f6f7972272..aafd2c0e6d5 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -355,6 +355,35 @@ describe('processContextsServer - file_selection contexts', () => { expect(result).toEqual([]) }) + + it('widens the code fence so an embedded ``` block cannot close it early', async () => { + getWorkspaceFile.mockResolvedValue({ name: 'readme.md', folderPath: null }) + + const snippet = 'before\n```ts\nconst x = 1\n```\nafter' + const result = await processContextsServer( + [ + { + kind: 'file_selection', + fileId: 'file-1', + label: 'readme.md:1-5', + text: snippet, + startLine: 1, + endLine: 5, + } as ChatContext, + ], + 'user-1', + 'explain', + 'ws-1' + ) + + const [ctx] = result + // Outer fence must be longer than the embedded ``` run, and the full snippet + // (including its inner fence) must survive intact. + expect(ctx.content).toContain('````') + expect(ctx.content).toContain(snippet) + expect(ctx.content.startsWith('Selected passage')).toBe(true) + expect(ctx.content.endsWith('````')).toBe(true) + }) }) describe('processContextsServer - table_selection contexts', () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 08565d33306..2b7c5fcb274 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -878,6 +878,21 @@ async function resolveFileResource( } } +/** + * Picks a backtick fence long enough to wrap `content` without an embedded + * backtick run closing it early. Per CommonMark, a fenced block ends only on a + * run of at least as many backticks as the opener, so the fence is one longer + * than the longest run inside the content, floored at the standard three. Keeps + * a selection that itself contains a ``` code block from truncating the snippet. + */ +function codeFenceFor(content: string): string { + let longest = 0 + for (const match of content.matchAll(/`+/g)) { + longest = Math.max(longest, match[0].length) + } + return '`'.repeat(Math.max(3, longest + 1)) +} + /** * Resolves a highlighted passage from a file into an inline, citable snippet. * The selected text travels with the request (it is the user's own content), so @@ -902,7 +917,8 @@ async function resolveFileSelectionResource( : startLine ? ` (line ${startLine})` : '' - const content = `Selected passage from ${record.name}${lineRange}:\n\n\`\`\`\n${snippet}\n\`\`\`` + const fence = codeFenceFor(snippet) + const content = `Selected passage from ${record.name}${lineRange}:\n\n${fence}\n${snippet}\n${fence}` return { type: 'file_selection', tag: label ? `@${label}` : '@', From 1551f967089ac1d5f45e7cbef7eed11f73048d47 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 29 Jul 2026 21:53:34 -0700 Subject: [PATCH 03/26] fix(chat): carry the selection chip on a column-header copy Cursor: a column-header Cmd+C always took the async paged clipboard path, which replaces the whole clipboard with text/plain only and can't carry a custom MIME - so pasting a column copy into Chat couldn't rebuild the table_selection chip that Add-to-chat produces for the same selection. When every row is loaded and within the chat-selection cap, the column copy now does a synchronous event write so the scoped table_selection rides alongside text/plain (mirroring the row-'some' and cell-range paths); oversized/partially-loaded columns keep the async plain-text path. --- .../components/table-grid/table-grid.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 2908af97268..b31b7162310 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -2932,6 +2932,50 @@ export function TableGrid({ if (name) colNames.push(name) } const colByKey = new Map(cols.map((c) => [c.key, c])) + + // When every row is loaded and within the chat-selection cap, do a + // SYNCHRONOUS event write so the table_selection chip rides alongside + // text/plain — the async paged path below replaces the whole clipboard + // and can't carry a custom MIME. Add-to-chat materializes the same + // scoped selection, so Cmd+C must be able to rebuild that chip. + const allLoaded = currentRows.length >= selectAllTotalRef.current + if ( + tableNameRef.current && + allLoaded && + currentRows.length > 0 && + currentRows.length <= MAX_TABLE_SELECTION_ROWS + ) { + const text = currentRows + .map((row) => + colNames.map((name) => cellToText(row.data[name], colByKey.get(name))).join('\t') + ) + .join('\n') + e.clipboardData?.setData('text/plain', text) + const rowIds = currentRows.map((row) => row.id) + const columnIds: string[] = [] + for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { + columnIds.push(getColumnId(cols[c])) + } + const scopedColumnIds = + columnIds.length > 0 && columnIds.length < cols.length + ? columnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) + : undefined + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rowIds.length, + scopedColumnIds?.length, + selectionKey([...rowIds, ...(scopedColumnIds ?? [])]) + ), + rowIds, + ...(scopedColumnIds ? { columnIds: scopedColumnIds } : {}), + }) + toast.success(`Copied ${rowIds.length} ${rowIds.length === 1 ? 'row' : 'rows'}`) + return + } + writeSelectionToClipboard({ loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), selectRow: () => true, From 642199446cc58299630048c7692f5630bde49a8c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 21:53:33 -0700 Subject: [PATCH 04/26] refactor(chat): clean up highlight-to-chat selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Stop reporting fabricated line numbers for rich-markdown selections. `doc.textBetween` counts ProseMirror block boundaries, not markdown source lines, so the chip label and the agent prompt both claimed line ranges that don't exist in the file. Line info is now emitted only by Monaco. - Bound a table_selection's rendered markdown by characters, not just row and column counts — 500 wide rows dwarfed the 20k-char file-selection budget. Rows are emitted until the budget is spent, and the content says what was omitted. - Complete `areContextsEqual` for both selection kinds, so re-adding the same selection dedupes while a different passage of an already-referenced file registers as new. Consistency: - Carry the resource display name on the context instead of recovering it by regex from the chip label, deleting fileNameFromSelectionLabel and tableNameFromSelectionLabel. - Replace the user-visible `#k3f9` hash disambiguator with a readable ordinal applied at insert time (`Sales (3 rows) (2)`), via a shared prepareContextForInsert used by both the add-to-chat and paste paths. - Fold MothershipPendingContextStorage into MothershipHandoffStorage as a chip-only handoff (optional message), removing the parallel storage class, the second drain effect, and its StrictMode guard ref. - Replace `window.location.assign` with `router.push`, matching the existing "Troubleshoot in Chat" handoff. - Collapse three near-duplicate synchronous copy branches in table-grid into shared buildTableSelectionContext / writeLoadedRowsWithChip helpers, also reused by the add-to-chat handler. - Trim multi-paragraph inline comments to TSDoc stating each reason once. Tests: new coverage for the character budget, the label ordinal, selection equality, and chip-only handoff accumulation; each verified to fail when its fix is reverted. --- .../rich-markdown-editor.tsx | 17 +- .../components/file-viewer/text-editor.tsx | 4 +- .../file-viewer/use-selection-copy-bridge.ts | 15 +- .../chat-context-kind-registry.tsx | 7 +- .../components/chip-clipboard-codec.ts | 10 +- .../prompt-editor/use-prompt-editor.ts | 53 ++-- .../app/workspace/[workspaceId]/home/home.tsx | 63 ++--- .../[workspaceId]/home/hooks/use-chat.ts | 9 +- .../app/workspace/[workspaceId]/home/types.ts | 4 + .../components/table-grid/table-grid.tsx | 249 +++++++++--------- .../components/user-input/utils.test.ts | 81 ++++++ .../copilot/components/user-input/utils.ts | 64 +++++ apps/sim/hooks/use-add-to-chat.ts | 26 +- apps/sim/lib/copilot/chat/post.ts | 2 + .../lib/copilot/chat/process-contents.test.ts | 67 +++++ apps/sim/lib/copilot/chat/process-contents.ts | 44 ++-- .../copilot/chat/selection-clipboard.test.ts | 22 +- .../lib/copilot/chat/selection-clipboard.ts | 8 +- .../copilot/chat/selection-context.test.ts | 47 +--- .../sim/lib/copilot/chat/selection-context.ts | 78 ++---- .../lib/core/utils/browser-storage.test.ts | 53 +++- apps/sim/lib/core/utils/browser-storage.ts | 119 +++------ apps/sim/lib/mothership/events.ts | 48 ++-- apps/sim/stores/panel/types.ts | 20 +- 24 files changed, 663 insertions(+), 447 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index ac46fe1e1f9..2d0e2c6d777 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -14,7 +14,6 @@ import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' import { buildFileSelectionLabel, - selectionKey, truncateSelectionText, } from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -1133,23 +1132,25 @@ export function LoadedRichMarkdownEditor({ ) const addToChat = useAddToChat() + /** + * No line range: this editor renders a ProseMirror document, whose block + * boundaries do not correspond to markdown source lines (blank lines between + * paragraphs, list markers, heading prefixes and fenced blocks all shift the + * real line). Reporting a derived count would label the chip — and prompt the + * agent — with line numbers that don't exist in the file. + */ const buildSelectionContext = useCallback((): ChatContext | null => { if (!editor) return null const { from, to } = editor.state.selection if (from === to) return null const text = editor.state.doc.textBetween(from, to, '\n') if (!text.trim()) return null - // Markdown has no native line numbers; approximate from newlines before the - // selection so repeated selections in the same file get distinct labels. - const startLine = editor.state.doc.textBetween(0, from, '\n').split('\n').length - const endLine = startLine + text.split('\n').length - 1 return { kind: 'file_selection', fileId: file.id, - label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + fileName: file.name, + label: buildFileSelectionLabel(file.name), text: truncateSelectionText(text), - startLine, - endLine, } }, [editor, file.id, file.name]) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 10b5f30174b..580a4538455 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -7,7 +7,6 @@ import type { editor as MonacoEditorTypes } from 'monaco-editor' import dynamic from 'next/dynamic' import { buildFileSelectionLabel, - selectionKey, truncateSelectionText, } from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -395,7 +394,8 @@ export const TextEditor = memo(function TextEditor({ return { kind: 'file_selection', fileId: file.id, - label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + fileName: file.name, + label: buildFileSelectionLabel(file.name, startLine, endLine), text: truncateSelectionText(text), startLine, endLine, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts index 933ec91c1b1..a203880eca5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts @@ -8,15 +8,14 @@ import type { ChatContext } from '@/stores/panel' * Rides a selection {@link ChatContext} onto the editor's native copy so a * highlighted passage copied with Cmd+C pastes into Chat as a reference chip. * - * The listener is attached to `containerRef` in the BUBBLE phase so it runs - * AFTER the inner editor's own copy handler (Monaco and ProseMirror both call - * `clearData()` then write `text/plain`/`text/html`) — the custom - * `text/x-sim-selection` type is added last and survives, leaving normal copy - * untouched. `buildContext` returns `null` when there is no non-empty selection. + * Attached in the BUBBLE phase so it runs after the inner editor's own copy + * handler — Monaco and ProseMirror both `clearData()` before writing + * `text/plain`, so the custom type must be added last to survive. * - * `enabled` lets a caller whose container mounts late (e.g. behind a loading - * gate) re-run the effect once the node exists — a ref object isn't reactive, so - * without it the effect would bail on the first render and never re-attach. + * @param buildContext - Returns null when there is no non-empty selection. + * @param enabled - Re-runs the effect for a container that mounts late (behind a + * loading gate); a ref isn't reactive, so the effect would otherwise bail on the + * first render and never re-attach. */ export function useSelectionCopyBridge( containerRef: RefObject, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index c6b5a965770..5e78d0b8acb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -12,7 +12,6 @@ import { } from '@sim/emcn/icons' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' -import { fileNameFromSelectionLabel } from '@/lib/copilot/chat/selection-context' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle } from '@/blocks/brand-icon-style' import { getBlockRegistry } from '@/blocks/registry' @@ -90,9 +89,9 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record { - // Strip the `:line` suffix so `getDocumentIcon` reads the real extension - // (e.g. `md`, not `md:12-40`) and shows the correct file glyph. - const FileDocIcon = getDocumentIcon('', fileNameFromSelectionLabel(context.label)) + // The label carries a `:line` suffix, so read the extension off the file + // name the context carries — `getDocumentIcon` needs `md`, not `md:12-40`. + const FileDocIcon = getDocumentIcon('', context.fileName ?? context.label) return }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 90a78369cab..e2166acd6cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -162,11 +162,11 @@ export function serializeSelectionForClipboard( /** * Finds the selection-scoped chips (`file_selection` / `table_selection`) whose - * highlighted token falls inside `selectedText`. These kinds carry an inline - * text blob / row-id array that can't fit a portable `sim:kind/id` link, so the - * chat input's copy/cut path round-trips them through the custom - * `text/x-sim-selection` clipboard MIME instead. Uses the overlay's exact - * tokenization so a label that is a substring of another never false-matches. + * highlighted token falls inside `selectedText` — the chips the copy/cut path + * must route through the custom clipboard MIME rather than a portable link. + * + * Uses the overlay's exact tokenization so a label that is a substring of + * another never false-matches. */ export function selectionContextsInText( selectedText: string, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index e9893f96902..d709948d1f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -25,7 +25,7 @@ import { useMentionTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import { - isContextAlreadySelected, + prepareContextForInsert, restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -475,10 +475,11 @@ export function usePromptEditor({ */ const insertContextChip = useCallback( (context: ChatContext) => { - // A chip's `@label` token must be unique — `addContext` dedupes a matching - // label, so inserting a token for an already-present context would orphan - // it (a second token with no backing context). Skip and just focus. - if (isContextAlreadySelected(context, contextManagementRef.current.selectedContexts)) { + const prepared = prepareContextForInsert( + context, + contextManagementRef.current.selectedContexts + ) + if (!prepared) { textareaRef.current?.focus() return } @@ -487,16 +488,15 @@ export function usePromptEditor({ const currentValue = valueRef.current const insertAt = textarea.selectionStart ?? currentValue.length const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) - const insertText = `${needsSpaceBefore ? ' ' : ''}@${context.label} ` + const insertText = `${needsSpaceBefore ? ' ' : ''}@${prepared.label} ` const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}` - const newPos = insertAt + insertText.length - pendingCursorRef.current = newPos + pendingCursorRef.current = insertAt + insertText.length valueRef.current = newValue setValueState(newValue) } - addContextNotified(context) + addContextNotified(prepared) }, [textareaRef, addContextNotified] ) @@ -923,25 +923,20 @@ export function usePromptEditor({ const selectionContext = readSelectionContextFromClipboard(e.clipboardData) if (selectionContext) { e.preventDefault() - // A chip's `@label` token must be unique — `addContext` dedupes a matching - // label, so inserting a token for an already-present selection would orphan - // it (a second token with no backing context). Skip and keep focus, mirroring - // insertContextChip. - if ( - isContextAlreadySelected(selectionContext, contextManagementRef.current.selectedContexts) - ) { - return - } + const prepared = prepareContextForInsert( + selectionContext, + contextManagementRef.current.selectedContexts + ) + if (!prepared) return const selStart = textarea.selectionStart ?? valueRef.current.length const selEnd = textarea.selectionEnd ?? selStart const needsSpaceBefore = selStart > 0 && !/\s/.test(valueRef.current.charAt(selStart - 1)) - const insert = `${needsSpaceBefore ? ' ' : ''}@${selectionContext.label} ` + const insert = `${needsSpaceBefore ? ' ' : ''}@${prepared.label} ` textarea.setRangeText(insert, selStart, selEnd, 'end') - const newValue = textarea.value const caret = selStart + insert.length - contextManagementRef.current.addContext(selectionContext) - valueRef.current = newValue - setValueState(newValue) + contextManagementRef.current.addContext(prepared) + valueRef.current = textarea.value + setValueState(textarea.value) requestAnimationFrame(() => textarea.setSelectionRange(caret, caret)) return } @@ -1038,14 +1033,10 @@ export function usePromptEditor({ * (the caller must then perform the cut deletion itself, since the default * was prevented). * - * Selection chips (`file_selection` / `table_selection`) can't fit a portable - * link — their inline text / row-id payload lives only in the context. When - * the selection is exactly one such chip (the common copy/cut of a - * highlight-to-chat chip), ride its full context on the custom - * `text/x-sim-selection` MIME so paste restores it; otherwise it would leave a - * bare `@label` with no backing data. Mixed selections keep the portable/plain - * path (the single-slot MIME can't carry more than one), so the chip degrades - * to its label text there rather than dropping the rest of the selection. + * Selection chips carry an inline text / row-id payload that no portable link + * can hold, so a lone selection chip rides the custom `text/x-sim-selection` + * MIME instead. That slot fits only one, so a mixed selection keeps the + * portable path and its selection chip degrades to bare label text. */ const writeSanitizedClipboard = useCallback( (e: React.ClipboardEvent): boolean => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 339a0bd3d23..a6f860924e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,17 +19,12 @@ import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' -import { - fileNameFromSelectionLabel, - tableNameFromSelectionLabel, -} from '@/lib/copilot/chat/selection-context' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, MothershipHandoffStorage, - MothershipPendingContextStorage, } from '@/lib/core/utils/browser-storage' import { addMothershipContext, @@ -335,43 +330,37 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) }, [sendMessage]) /** - * Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in - * Chat" on an errored log viewed from a different route) and auto-sends it - * into this fresh chat, tagging the run so Sim can inspect the failure. Only - * the cross-route path lands here — when a chat is already mounted the event - * above delivers directly. Gated to the new-chat surface (`!chatId`): a + * Consumes a one-shot handoff left by another surface and applies it to this + * fresh chat. Two shapes arrive here: a message handoff (e.g. "Troubleshoot in + * Chat" on an errored log) is auto-sent with its contexts attached; a + * chip-only handoff (highlight-to-chat from the standalone Files/Tables pages) + * seeds reference chips and sends nothing. + * + * Only the cross-route path lands here — when a chat is already mounted the + * events deliver directly. Gated to the new-chat surface (`!chatId`): a * handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount * must never claim it if navigation races. `consume` clears the entry * atomically, so it fires at most once even across a StrictMode remount. + * + * Chip-only handoffs open each resource directly rather than relying on the + * input's listener being mounted, then dispatch so the input inserts the chip. + * This effect is declared after `useChat`, so its chat-init `setResources([])` + * has already flushed and cannot wipe the just-opened resource. */ useEffect(() => { if (chatId) return const handoff = MothershipHandoffStorage.consume(workspaceId) - if (handoff) sendMessage(handoff.message, undefined, handoff.contexts) - }, [chatId, workspaceId, sendMessage]) - - /** - * Drains contexts persisted by the highlight-to-chat action (standalone - * Files/Tables page). Runs after this component's mount effects — including - * `useChat`'s chat-init `setResources([])` — so re-dispatching each context - * inserts its chip in the (already mounted) conversation input AND opens its - * resource in the slideover without the reset wiping it. A ref guards against - * the StrictMode double-invoke draining twice. - */ - const hasDrainedPendingContextRef = useRef(false) - useEffect(() => { - if (hasDrainedPendingContextRef.current || !workspaceId) return - hasDrainedPendingContextRef.current = true - const pending = MothershipPendingContextStorage.consume(workspaceId) - for (const context of pending) { - // Open the resource in the slideover directly (deterministic — not - // dependent on the input's event listener being mounted yet), then - // dispatch the event so the mounted input inserts the chip. + if (!handoff) return + if (handoff.message) { + sendMessage(handoff.message, undefined, handoff.contexts) + return + } + for (const context of handoff.contexts ?? []) { handleContextAdd(context) addMothershipContext(context) } - // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only drain; handleContextAdd is stable enough for a one-shot - }, [workspaceId]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot drain; handleContextAdd is a stable body function + }, [chatId, workspaceId, sendMessage]) function resolveResourceFromContext( context: ChatContext @@ -396,13 +385,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) } /** - * Tab title for the resource a chip opens. Selection chips carry a - * location suffix in their label (`notes.md:12-40`, `Sales (3 rows)`); the - * underlying resource is the whole file/table, so strip the suffix. + * Tab title for the resource a chip opens. A selection chip's label describes + * the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the + * whole file/table, so title it from the resource name the context carries. */ function resourceTitleForContext(context: ChatContext): string { - if (context.kind === 'file_selection') return fileNameFromSelectionLabel(context.label) - if (context.kind === 'table_selection') return tableNameFromSelectionLabel(context.label) + if (context.kind === 'file_selection') return context.fileName + if (context.kind === 'table_selection') return context.tableName return context.label } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 14a5bf9cd16..17fee29df74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -318,13 +318,18 @@ function isChatContext(value: unknown): value is ChatContext { case 'table_selection': return ( typeof value.tableId === 'string' && + typeof value.tableName === 'string' && Array.isArray(value.rowIds) && value.rowIds.every((id) => typeof id === 'string') ) case 'file': return typeof value.fileId === 'string' case 'file_selection': - return typeof value.fileId === 'string' && typeof value.text === 'string' + return ( + typeof value.fileId === 'string' && + typeof value.fileName === 'string' && + typeof value.text === 'string' + ) case 'folder': return typeof value.folderId === 'string' case 'filefolder': @@ -3231,6 +3236,7 @@ export function useChat( ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), ...(c.kind === 'file_selection' ? { + fileName: c.fileName, text: c.text, ...(c.startLine ? { startLine: c.startLine } : {}), ...(c.endLine ? { endLine: c.endLine } : {}), @@ -3238,6 +3244,7 @@ export function useChat( : {}), ...(c.kind === 'table_selection' ? { + tableName: c.tableName, rowIds: c.rowIds, ...(c.columnIds ? { columnIds: c.columnIds } : {}), } diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index b06b0cd437c..4f97cc541b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -150,9 +150,13 @@ export interface ChatMessageContext { serverId?: string /** Selected passage for a `file_selection` context. */ text?: string + /** Source file name for a `file_selection` context. */ + fileName?: string /** 1-based inclusive line range for a `file_selection` context. */ startLine?: number endLine?: number + /** Source table name for a `table_selection` context. */ + tableName?: string /** Selected row ids for a `table_selection` context. */ rowIds?: string[] /** Selected column ids for a `table_selection` cell range. */ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 584514794d0..843d47002af 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -16,7 +16,6 @@ import { buildTableSelectionLabel, MAX_TABLE_SELECTION_COLUMNS, MAX_TABLE_SELECTION_ROWS, - selectionKey, } from '@/lib/copilot/chat/selection-context' import { captureEvent } from '@/lib/posthog/client' import type { @@ -51,6 +50,7 @@ import { import { useAddToChat } from '@/hooks/use-add-to-chat' import { useInlineRename } from '@/hooks/use-inline-rename' import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' +import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' import { useContextMenu, useTable } from '../../hooks' import type { EditingCell, QueryOptions, SaveReason } from '../../types' @@ -314,6 +314,77 @@ function cellToText(value: unknown, column?: DisplayColumn): string { return typeof value === 'object' ? JSON.stringify(value) : String(value) } +/** Column ids spanned by a normalized selection's column range. */ +function selectedColumnIds( + columns: DisplayColumn[], + selection: { startCol: number; endCol: number } +): string[] { + const ids: string[] = [] + for (let c = selection.startCol; c <= selection.endCol && c < columns.length; c++) { + ids.push(getColumnId(columns[c])) + } + return ids +} + +/** + * Materializes a `table_selection` chat context from a grid selection, applying + * the shared row/column caps. `columnIds` narrows the context to a cell range; a + * range covering every column is equivalent to whole rows, so it collapses to an + * open scope (the server then includes all columns, and stays correct if the + * schema changes). Returns null before the table name has loaded or when nothing + * is selected. + */ +function buildTableSelectionContext(opts: { + tableId: string + tableName: string | undefined + totalColumnCount: number + rowIds: string[] + columnIds?: string[] +}): ChatContext | null { + const { tableId, tableName, totalColumnCount, columnIds } = opts + if (!tableName || opts.rowIds.length === 0) return null + const rowIds = opts.rowIds.slice(0, MAX_TABLE_SELECTION_ROWS) + const scopedColumnIds = + columnIds && columnIds.length > 0 && columnIds.length < totalColumnCount + ? columnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) + : undefined + return { + kind: 'table_selection', + tableId, + tableName, + label: buildTableSelectionLabel(tableName, rowIds.length, scopedColumnIds?.length), + rowIds, + ...(scopedColumnIds ? { columnIds: scopedColumnIds } : {}), + } +} + +/** + * Copies `rows` synchronously on the copy event so a chat-selection chip can + * ride alongside the tab-separated text. Only viable when every selected row is + * already loaded and the set is within the chip cap — otherwise the caller falls + * back to the paged `writeSelectionToClipboard`, whose async Clipboard API write + * replaces the whole clipboard and so cannot carry a custom MIME type. + * + * @returns True when it handled the copy, false to fall through to the paged path. + */ +function writeLoadedRowsWithChip(opts: { + clipboardData: DataTransfer | null + rows: TableRowType[] + allLoaded: boolean + buildCells: (row: TableRowType) => string[] + context: ChatContext | null +}): boolean { + const { rows } = opts + if (!opts.allLoaded || rows.length === 0 || rows.length > MAX_TABLE_SELECTION_ROWS) return false + opts.clipboardData?.setData( + 'text/plain', + rows.map((row) => opts.buildCells(row).join('\t')).join('\n') + ) + if (opts.context) attachSelectionContextToClipboard(opts.clipboardData, opts.context) + toast.success(`Copied ${rows.length} ${rows.length === 1 ? 'row' : 'rows'}`) + return true +} + /** * Value-equality for a cell's stored value vs a pending edit. Primitives compare * with `===`; arrays/objects (multiselect id arrays, json) compare structurally @@ -2936,44 +3007,23 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - // The async Clipboard API (`navigator.clipboard.write`) used by the paged - // path below replaces the whole clipboard and can't carry a custom MIME - // type. For an explicit multi-row ('some') selection whose rows are all - // loaded, do a SYNCHRONOUS event write instead so the chat-selection chip - // rides alongside `text/plain` (same as the cell-range path). 'all' - // (filtered select-all) and oversized selections fall through to the - // paged plain-text write, which can't carry the chip. + // A filtered select-all ('all') covers rows beyond the loaded page, so + // only an explicit multi-row selection can take the chip-carrying path. if (rowSel.kind === 'some') { const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id)) - const allLoaded = selectedRows.length === rowSel.ids.size - if ( - allLoaded && - selectedRows.length > 0 && - selectedRows.length <= MAX_TABLE_SELECTION_ROWS - ) { - const text = selectedRows - .map((row) => cols.map((col) => cellToText(row.data[col.key], col)).join('\t')) - .join('\n') - e.clipboardData?.setData('text/plain', text) - if (tableNameRef.current) { - const rowIds = selectedRows.map((row) => row.id) - attachSelectionContextToClipboard(e.clipboardData, { - kind: 'table_selection', - tableId, - label: buildTableSelectionLabel( - tableNameRef.current, - rowIds.length, - undefined, - selectionKey(rowIds) - ), - rowIds, - }) - } - toast.success( - `Copied ${selectedRows.length} ${selectedRows.length === 1 ? 'row' : 'rows'}` - ) - return - } + const handled = writeLoadedRowsWithChip({ + clipboardData: e.clipboardData, + rows: selectedRows, + allLoaded: selectedRows.length === rowSel.ids.size, + buildCells: (row) => cols.map((col) => cellToText(row.data[col.key], col)), + context: buildTableSelectionContext({ + tableId, + tableName: tableNameRef.current, + totalColumnCount: cols.length, + rowIds: selectedRows.map((row) => row.id), + }), + }) + if (handled) return } writeSelectionToClipboard({ loadRows: @@ -3004,48 +3054,23 @@ export function TableGrid({ } const colByKey = new Map(cols.map((c) => [c.key, c])) - // When every row is loaded and within the chat-selection cap, do a - // SYNCHRONOUS event write so the table_selection chip rides alongside - // text/plain — the async paged path below replaces the whole clipboard - // and can't carry a custom MIME. Add-to-chat materializes the same - // scoped selection, so Cmd+C must be able to rebuild that chip. - const allLoaded = currentRows.length >= selectAllTotalRef.current - if ( - tableNameRef.current && - allLoaded && - currentRows.length > 0 && - currentRows.length <= MAX_TABLE_SELECTION_ROWS - ) { - const text = currentRows - .map((row) => - colNames.map((name) => cellToText(row.data[name], colByKey.get(name))).join('\t') - ) - .join('\n') - e.clipboardData?.setData('text/plain', text) - const rowIds = currentRows.map((row) => row.id) - const columnIds: string[] = [] - for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { - columnIds.push(getColumnId(cols[c])) - } - const scopedColumnIds = - columnIds.length > 0 && columnIds.length < cols.length - ? columnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) - : undefined - attachSelectionContextToClipboard(e.clipboardData, { - kind: 'table_selection', + // A column-header selection spans every row, so the chip-carrying path + // only applies once the whole table is loaded. + const handled = writeLoadedRowsWithChip({ + clipboardData: e.clipboardData, + rows: currentRows, + allLoaded: currentRows.length >= selectAllTotalRef.current, + buildCells: (row) => + colNames.map((name) => cellToText(row.data[name], colByKey.get(name))), + context: buildTableSelectionContext({ tableId, - label: buildTableSelectionLabel( - tableNameRef.current, - rowIds.length, - scopedColumnIds?.length, - selectionKey([...rowIds, ...(scopedColumnIds ?? [])]) - ), - rowIds, - ...(scopedColumnIds ? { columnIds: scopedColumnIds } : {}), - }) - toast.success(`Copied ${rowIds.length} ${rowIds.length === 1 ? 'row' : 'rows'}`) - return - } + tableName: tableNameRef.current, + totalColumnCount: cols.length, + rowIds: currentRows.map((row) => row.id), + columnIds: selectedColumnIds(cols, sel), + }), + }) + if (handled) return writeSelectionToClipboard({ loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), @@ -3058,41 +3083,21 @@ export function TableGrid({ return } - // Ride a table_selection (bounded rows + the range's columns) onto the - // clipboard so pasting this cell range into Chat yields the same chip. - if (tableNameRef.current) { - const rangeRowIds: string[] = [] - for ( - let r = sel.startRow; - r <= sel.endRow && rangeRowIds.length < MAX_TABLE_SELECTION_ROWS; - r++ - ) { - const row = currentRows[r] - if (row) rangeRowIds.push(row.id) - } - const rangeColumnIds: string[] = [] - for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { - rangeColumnIds.push(getColumnId(cols[c])) - } - const columnIds = - rangeColumnIds.length > 0 && rangeColumnIds.length < cols.length - ? rangeColumnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) - : undefined - if (rangeRowIds.length > 0) { - attachSelectionContextToClipboard(e.clipboardData, { - kind: 'table_selection', - tableId, - label: buildTableSelectionLabel( - tableNameRef.current, - rangeRowIds.length, - columnIds?.length, - selectionKey([...rangeRowIds, ...(columnIds ?? [])]) - ), - rowIds: rangeRowIds, - ...(columnIds ? { columnIds } : {}), - }) - } + // The cell-range write below is already synchronous, so the chip simply + // rides along on the same event. + const rangeRowIds: string[] = [] + for (let r = sel.startRow; r <= sel.endRow; r++) { + const row = currentRows[r] + if (row) rangeRowIds.push(row.id) } + const rangeContext = buildTableSelectionContext({ + tableId, + tableName: tableNameRef.current, + totalColumnCount: cols.length, + rowIds: rangeRowIds, + columnIds: selectedColumnIds(cols, sel), + }) + if (rangeContext) attachSelectionContextToClipboard(e.clipboardData, rangeContext) const lines: string[] = [] for (let r = sel.startRow; r <= sel.endRow; r++) { @@ -3862,21 +3867,14 @@ export function TableGrid({ // Fall back to the already-loaded rows if the drain fails. } } - if (sourceRowIds.length === 0) return - const rowIds = sourceRowIds.slice(0, MAX_TABLE_SELECTION_ROWS) - const columnIds = contextMenuColumnIds?.slice(0, MAX_TABLE_SELECTION_COLUMNS) - addToChat({ - kind: 'table_selection', + const context = buildTableSelectionContext({ tableId, - label: buildTableSelectionLabel( - tableData?.name ?? 'Table', - rowIds.length, - columnIds?.length, - selectionKey([...rowIds, ...(columnIds ?? [])]) - ), - rowIds, - ...(columnIds && columnIds.length > 0 ? { columnIds } : {}), + tableName: tableData?.name, + totalColumnCount: displayColumns.length, + rowIds: sourceRowIds, + columnIds: contextMenuColumnIds, }) + if (context) addToChat(context) }, [ addToChat, contextMenuRowIds, @@ -3884,6 +3882,7 @@ export function TableGrid({ contextMenuIsSelectAll, tableId, tableData?.name, + displayColumns.length, ]) const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts new file mode 100644 index 00000000000..54a4922195c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + prepareContextForInsert, + uniqueContextLabel, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { ChatContext } from '@/stores/panel' + +function fileSelection(overrides: Partial> = {}) { + return { + kind: 'file_selection', + fileId: 'file-1', + fileName: 'notes.md', + label: 'notes.md:12-40', + text: 'the exact passage', + ...overrides, + } as ChatContext +} + +function tableSelection( + overrides: Partial> = {} +) { + return { + kind: 'table_selection', + tableId: 'tbl-1', + tableName: 'Sales', + label: 'Sales (3 rows)', + rowIds: ['r1', 'r2', 'r3'], + ...overrides, + } as ChatContext +} + +describe('uniqueContextLabel', () => { + it('returns the label untouched when it is free', () => { + expect(uniqueContextLabel('Sales (3 rows)', [])).toBe('Sales (3 rows)') + }) + + it('appends an ordinal on collision, and keeps counting', () => { + const taken = [tableSelection(), tableSelection({ label: 'Sales (3 rows) (2)' })] + + expect(uniqueContextLabel('Sales (3 rows)', taken)).toBe('Sales (3 rows) (3)') + }) +}) + +describe('prepareContextForInsert', () => { + it('rejects re-adding the exact same selection', () => { + expect(prepareContextForInsert(tableSelection(), [tableSelection()])).toBeNull() + expect(prepareContextForInsert(fileSelection(), [fileSelection()])).toBeNull() + }) + + it('relabels a different selection that describes itself the same way', () => { + // Two distinct 3-row picks from one table both read "Sales (3 rows)". Chips + // are keyed by their @label, so the second must be relabeled, not dropped. + const other = tableSelection({ rowIds: ['r7', 'r8', 'r9'] }) + + expect(prepareContextForInsert(other, [tableSelection()])).toEqual({ + ...other, + label: 'Sales (3 rows) (2)', + }) + }) + + it('treats a different passage of the same file as a new selection', () => { + const other = fileSelection({ text: 'a different passage' }) + + expect(prepareContextForInsert(other, [fileSelection()])).not.toBeNull() + }) + + it('distinguishes a cell range from the whole rows it spans', () => { + const range = tableSelection({ columnIds: ['c_name'] }) + + expect(prepareContextForInsert(range, [tableSelection()])).not.toBeNull() + }) + + it('passes an uncontested context through unchanged', () => { + const context = tableSelection() + + expect(prepareContextForInsert(context, [])).toEqual(context) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 32aaf9aa159..db73af9b728 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -190,6 +190,15 @@ type IntegrationContext = Extract type SlashCommandContext = Extract type SkillContext = Extract type McpContext = Extract +type FileSelectionContext = Extract +type TableSelectionContext = Extract + +/** Order-sensitive equality for two optional id lists. */ +function sameIds(a: string[] | undefined, b: string[] | undefined): boolean { + if (a === b) return true + if (!a || !b || a.length !== b.length) return false + return a.every((id, i) => id === b[i]) +} /** * Checks if two contexts of the same kind are equal by their ID fields. @@ -231,6 +240,21 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as FileContext return c.fileId === ctx.fileId } + // Selection kinds scope to part of a resource, so equality is the selected + // range — not the file/table — or re-selecting a different passage of an + // already-referenced file would be swallowed as a duplicate. + case 'file_selection': { + const ctx = context as FileSelectionContext + return c.fileId === ctx.fileId && c.text === ctx.text + } + case 'table_selection': { + const ctx = context as TableSelectionContext + return ( + c.tableId === ctx.tableId && + sameIds(c.rowIds, ctx.rowIds) && + sameIds(c.columnIds, ctx.columnIds) + ) + } case 'logs': { const ctx = context as LogsContext return c.executionId === ctx.executionId @@ -299,3 +323,43 @@ export function isContextAlreadySelected( return areContextsEqual(c, context) }) } + +/** + * Returns `label`, or the first free `label (n)` variant when it is already + * taken. Two genuinely different selections can legitimately describe + * themselves the same way — two 3-row picks from one table both read + * `Sales (3 rows)` — but the token system keys chips by their `@label`, so a + * collision would silently drop the second context. The ordinal keeps both + * chips alive and stays readable in the input, unlike an opaque hash. + * + * Only meaningful for programmatically inserted contexts; menu-driven picks + * name a distinct resource and dedupe correctly via + * {@link isContextAlreadySelected}. + */ +export function uniqueContextLabel(label: string, selectedContexts: ChatContext[]): string { + const taken = new Set(selectedContexts.map((c) => c.label)) + if (!taken.has(label)) return label + for (let n = 2; ; n++) { + const candidate = `${label} (${n})` + if (!taken.has(candidate)) return candidate + } +} + +/** + * Insert policy for a context pushed into the input programmatically — the + * highlight-to-chat action and the selection paste, neither of which goes + * through a typed `@`/`/` trigger. + * + * @returns `null` when the exact context is already attached (re-adding the same + * selection is a no-op), otherwise the context carrying a collision-free label. + */ +export function prepareContextForInsert( + context: ChatContext, + selectedContexts: ChatContext[] +): ChatContext | null { + const isDuplicate = selectedContexts.some( + (c) => c.kind === context.kind && areContextsEqual(c, context) + ) + if (isDuplicate) return null + return { ...context, label: uniqueContextLabel(context.label, selectedContexts) } +} diff --git a/apps/sim/hooks/use-add-to-chat.ts b/apps/sim/hooks/use-add-to-chat.ts index d20668fd2f4..948835da8ba 100644 --- a/apps/sim/hooks/use-add-to-chat.ts +++ b/apps/sim/hooks/use-add-to-chat.ts @@ -1,8 +1,8 @@ 'use client' import { useCallback } from 'react' -import { useParams } from 'next/navigation' -import { MothershipPendingContextStorage } from '@/lib/core/utils/browser-storage' +import { useParams, useRouter } from 'next/navigation' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { addMothershipContext } from '@/lib/mothership/events' import type { ChatContext } from '@/stores/panel' @@ -11,24 +11,24 @@ import type { ChatContext } from '@/stores/panel' * without sending — the "add to chat" side of the highlight-to-chat flow. When a * chat input is mounted (e.g. the Chat surface alongside the file/table viewer) * the chip is inserted live and the source resource opens in the slideover. - * Otherwise the context is persisted and we navigate to the Chat, where it seeds - * the input and opens the resource on mount — the same slideover experience as - * opening a workflow or resource from within Chat. + * Otherwise the context is persisted as a chip-only handoff and we navigate to + * Chat, where it seeds the input and opens the resource on mount. + * + * Navigation is gated on a successful store, so a failed write never strands the + * user on an empty chat. */ export function useAddToChat(): (context: ChatContext) => void { const { workspaceId } = useParams<{ workspaceId: string }>() + const router = useRouter() return useCallback( (context: ChatContext) => { - const consumed = addMothershipContext(context) - if (consumed) return + if (addMothershipContext(context)) return if (!workspaceId) return - MothershipPendingContextStorage.store(context, workspaceId) - // Hard navigation (not router.push): a full Chat mount reliably opens the - // resource in the slideover. A client-side transition races with useChat's - // resource-reset effect and drops the just-opened resource. - window.location.assign(`/workspace/${workspaceId}/home`) + if (MothershipHandoffStorage.store({ contexts: [context] }, workspaceId)) { + router.push(`/workspace/${workspaceId}/home`) + } }, - [workspaceId] + [workspaceId, router] ) } diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index c6c2bc99a4b..4b802c1f27f 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -179,8 +179,10 @@ const ChatContextSchema = z.object({ tabId: z.string().optional(), terminalId: z.string().optional(), text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(), + fileName: z.string().optional(), startLine: z.number().int().positive().optional(), endLine: z.number().int().positive().optional(), + tableName: z.string().optional(), rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(), columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(), }) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index aafd2c0e6d5..64e01fe7058 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -4,6 +4,10 @@ import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAX_TABLE_SELECTION_CONTENT_LENGTH, + MAX_TABLE_SELECTION_ROWS, +} from '@/lib/copilot/chat/selection-context' import type { ChatContext } from '@/stores/panel' const { discoverServerTools, getSkillById, getWorkspaceFile, getTableById, getRowsByIds } = @@ -486,4 +490,67 @@ describe('processContextsServer - table_selection contexts', () => { expect(result).toEqual([]) }) + + it('spends a character budget across rows and reports what it omitted', async () => { + // Row/column caps alone don't bound prompt cost: wide cells blow past the + // budget long before MAX_TABLE_SELECTION_ROWS. + const wide = 'x'.repeat(2_000) + const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({ + id: `r${i}`, + data: { c_notes: wide }, + })) + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'c_notes', name: 'Notes' }] }, + }) + getRowsByIds.mockResolvedValue(rows) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + tableName: 'Sales', + label: 'Sales (500 rows)', + rowIds: rows.map((r) => r.id), + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + const [ctx] = result + expect(ctx.content.length).toBeLessThanOrEqual(MAX_TABLE_SELECTION_CONTENT_LENGTH) + expect(ctx.content).toContain('omitted for length') + }) + + it('emits at least one row even when that row alone exceeds the budget', async () => { + const huge = 'x'.repeat(MAX_TABLE_SELECTION_CONTENT_LENGTH * 2) + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'c_notes', name: 'Notes' }] }, + }) + getRowsByIds.mockResolvedValue([{ id: 'r1', data: { c_notes: huge } }]) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + tableName: 'Sales', + label: 'Sales (1 row)', + rowIds: ['r1'], + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + expect(result).toHaveLength(1) + expect(result[0].content).toContain(huge) + }) }) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 2b7c5fcb274..1765ba367ff 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,7 +6,10 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' -import { truncateSelectionText } from '@/lib/copilot/chat/selection-context' +import { + MAX_TABLE_SELECTION_CONTENT_LENGTH, + truncateSelectionText, +} from '@/lib/copilot/chat/selection-context' import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { @@ -933,6 +936,13 @@ async function resolveFileSelectionResource( * is present the projection is narrowed to that cell range, otherwise every * column is included. */ +/** Renders one cell for a markdown table row, escaping the delimiters. */ +function renderTableCell(value: unknown): string { + if (value === null || value === undefined) return '' + const cell = typeof value === 'string' ? value : JSON.stringify(value) + return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ') +} + async function resolveTableSelectionResource( tableId: string, workspaceId: string, @@ -960,22 +970,24 @@ async function resolveTableSelectionResource( const header = `| ${columns.map((c) => c.name).join(' | ')} |` const divider = `| ${columns.map(() => '---').join(' | ')} |` - const body = rows - .map((row) => { - const cells = columns.map((col) => { - const value = row.data[getColumnId(col)] - if (value === null || value === undefined) return '' - const cell = typeof value === 'string' ? value : JSON.stringify(value) - return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ') - }) - return `| ${cells.join(' | ')} |` - }) - .join('\n') - const scope = columnIds && columnIds.length > 0 ? 'cell range' : 'rows' - const content = `Selected ${scope} from table "${table.name}" (${rows.length} ${ - rows.length === 1 ? 'row' : 'rows' - }):\n\n${header}\n${divider}\n${body}` + // Spend the character budget row by row: the row cap alone doesn't bound the + // prompt cost. The first row is always emitted, so a single oversized row + // still yields a table rather than an empty one. + const lines: string[] = [] + let remaining = MAX_TABLE_SELECTION_CONTENT_LENGTH - header.length - divider.length + for (const row of rows) { + const line = `| ${columns.map((col) => renderTableCell(row.data[getColumnId(col)])).join(' | ')} |` + if (lines.length > 0 && line.length > remaining) break + lines.push(line) + remaining -= line.length + 1 + } + + const omitted = rows.length - lines.length + const shown = `${lines.length} ${lines.length === 1 ? 'row' : 'rows'}` + const scope = hasColumnScope ? 'cell range' : 'rows' + const size = omitted > 0 ? `${shown} of ${rows.length}, ${omitted} omitted for length` : shown + const content = `Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n${lines.join('\n')}` return { type: 'table_selection', tag: label ? `@${label}` : '@', diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.test.ts b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts index 4ba32165732..15108c9dec2 100644 --- a/apps/sim/lib/copilot/chat/selection-clipboard.test.ts +++ b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts @@ -23,6 +23,7 @@ function fakeClipboard(initial: Record = {}) { const fileSelection: ChatContext = { kind: 'file_selection', fileId: 'wf_1', + fileName: 'notes.md', label: 'notes.md:2-4', text: 'the exact passage', startLine: 2, @@ -32,6 +33,7 @@ const fileSelection: ChatContext = { const tableSelection: ChatContext = { kind: 'table_selection', tableId: 'tbl_1', + tableName: 'Sales', label: 'Sales (2 rows)', rowIds: ['r1', 'r2'], } @@ -68,7 +70,24 @@ describe('selection clipboard codec', () => { it('rejects a file selection missing its text', () => { const dt = fakeClipboard({ - [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file_selection', fileId: 'wf_1', label: 'x' }), + [SIM_SELECTION_MIME]: JSON.stringify({ + kind: 'file_selection', + fileId: 'wf_1', + fileName: 'notes.md', + label: 'x', + }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) + + it('rejects a selection missing the resource name the chip renders from', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ + kind: 'file_selection', + fileId: 'wf_1', + label: 'x', + text: 'passage', + }), }) expect(readSelectionContextFromClipboard(dt)).toBeNull() }) @@ -78,6 +97,7 @@ describe('selection clipboard codec', () => { [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'table_selection', tableId: 'tbl_1', + tableName: 'Sales', label: 'x', rowIds: [], }), diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.ts b/apps/sim/lib/copilot/chat/selection-clipboard.ts index 9ee48706ad8..4f031862e4f 100644 --- a/apps/sim/lib/copilot/chat/selection-clipboard.ts +++ b/apps/sim/lib/copilot/chat/selection-clipboard.ts @@ -41,12 +41,18 @@ export function readSelectionContextFromClipboard( if (!parsed || typeof parsed.label !== 'string') return null // Require each kind's resolving field so a chip never pastes only to // resolve to nothing server-side. - if (parsed.kind === 'file_selection' && typeof parsed.text === 'string' && parsed.fileId) { + if ( + parsed.kind === 'file_selection' && + typeof parsed.text === 'string' && + typeof parsed.fileName === 'string' && + parsed.fileId + ) { return parsed } if ( parsed.kind === 'table_selection' && parsed.tableId && + typeof parsed.tableName === 'string' && Array.isArray(parsed.rowIds) && parsed.rowIds.length > 0 ) { diff --git a/apps/sim/lib/copilot/chat/selection-context.test.ts b/apps/sim/lib/copilot/chat/selection-context.test.ts index ee62399dbc0..6df3d76b5db 100644 --- a/apps/sim/lib/copilot/chat/selection-context.test.ts +++ b/apps/sim/lib/copilot/chat/selection-context.test.ts @@ -5,9 +5,8 @@ import { describe, expect, it } from 'vitest' import { buildFileSelectionLabel, buildTableSelectionLabel, - fileNameFromSelectionLabel, - selectionKey, - tableNameFromSelectionLabel, + MAX_FILE_SELECTION_TEXT_LENGTH, + truncateSelectionText, } from './selection-context' describe('buildFileSelectionLabel', () => { @@ -23,14 +22,9 @@ describe('buildFileSelectionLabel', () => { expect(buildFileSelectionLabel('notes.md', 12)).toBe('notes.md:12') }) - it('falls back to just the file name with no line info', () => { + it('falls back to just the file name when the source has no line numbers', () => { expect(buildFileSelectionLabel('notes.md')).toBe('notes.md') }) - - it('appends the disambiguation key when provided', () => { - expect(buildFileSelectionLabel('notes.md', 12, 40, 'k3f9')).toBe('notes.md:12-40 #k3f9') - expect(buildFileSelectionLabel('notes.md', undefined, undefined, 'k3f9')).toBe('notes.md #k3f9') - }) }) describe('buildTableSelectionLabel', () => { @@ -43,37 +37,18 @@ describe('buildTableSelectionLabel', () => { expect(buildTableSelectionLabel('Sales', 5, 3)).toBe('Sales (5 rows, 3 cols)') expect(buildTableSelectionLabel('Sales', 2, 1)).toBe('Sales (2 rows, 1 col)') }) - - it('appends the disambiguation key when provided', () => { - expect(buildTableSelectionLabel('Sales', 2, undefined, 'k3f9')).toBe('Sales (2 rows #k3f9)') - expect(buildTableSelectionLabel('Sales', 2, 3, 'k3f9')).toBe('Sales (2 rows, 3 cols #k3f9)') - }) -}) - -describe('selectionKey', () => { - it('is deterministic and order-independent', () => { - expect(selectionKey(['r1', 'r2', 'r3'])).toBe(selectionKey(['r3', 'r1', 'r2'])) - }) - - it('differs for distinct id sets of the same size', () => { - expect(selectionKey(['r1', 'r2'])).not.toBe(selectionKey(['r3', 'r4'])) - }) }) -describe('selection-label name recovery', () => { - it('strips the line suffix from a file selection label', () => { - expect(fileNameFromSelectionLabel('notes.md:12-40')).toBe('notes.md') - expect(fileNameFromSelectionLabel('notes.md')).toBe('notes.md') +describe('truncateSelectionText', () => { + it('leaves text within the bound untouched', () => { + expect(truncateSelectionText('short passage')).toBe('short passage') }) - it('strips the disambiguation key and line suffix from a file selection label', () => { - expect(fileNameFromSelectionLabel('notes.md:12-40 #k3f9')).toBe('notes.md') - expect(fileNameFromSelectionLabel('notes.md #k3f9')).toBe('notes.md') - }) + it('keeps the result — ellipsis included — within the server schema bound', () => { + const oversized = 'x'.repeat(MAX_FILE_SELECTION_TEXT_LENGTH + 500) + const truncated = truncateSelectionText(oversized) - it('strips rows/cols/key suffix from a table selection label', () => { - expect(tableNameFromSelectionLabel('Sales (2 rows)')).toBe('Sales') - expect(tableNameFromSelectionLabel('Sales (2 rows, 3 cols #k3f9)')).toBe('Sales') - expect(tableNameFromSelectionLabel('Sales (5 rows #ab12)')).toBe('Sales') + expect(truncated.length).toBeLessThanOrEqual(MAX_FILE_SELECTION_TEXT_LENGTH) + expect(truncated.length).toBeLessThan(oversized.length) }) }) diff --git a/apps/sim/lib/copilot/chat/selection-context.ts b/apps/sim/lib/copilot/chat/selection-context.ts index 8f7e7e0422b..f655fe8140f 100644 --- a/apps/sim/lib/copilot/chat/selection-context.ts +++ b/apps/sim/lib/copilot/chat/selection-context.ts @@ -21,6 +21,15 @@ export const MAX_TABLE_SELECTION_ROWS = 500 /** Max columns referenced by a `table_selection` cell range. */ export const MAX_TABLE_SELECTION_COLUMNS = 200 +/** + * Max characters of rendered markdown a `table_selection` contributes to the + * prompt. Row and column caps alone don't bound this — 500 rows of wide cells + * dwarf a file selection — so the renderer spends this budget and reports what + * it dropped. Deliberately equal to {@link MAX_FILE_SELECTION_TEXT_LENGTH} so + * both selection kinds cost the prompt the same at worst. + */ +export const MAX_TABLE_SELECTION_CONTENT_LENGTH = MAX_FILE_SELECTION_TEXT_LENGTH + /** Length of the ellipsis {@link truncate} appends when it shortens a string. */ const TRUNCATE_SUFFIX_LENGTH = 3 @@ -36,74 +45,35 @@ export function truncateSelectionText(text: string): string { /** * Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40`, - * `notes.md:12`, or just `notes.md` when no line range is known. An optional - * trailing `key` (from {@link selectionKey}) disambiguates two distinct passages - * that share the same line range — without it their labels collide and the - * second chip is dropped (chips are keyed by their `@label`). Kept ASCII so the - * label survives being inserted as an inline mention token in the chat input. + * `notes.md:12`, or just `notes.md` when the source has no line numbers (the + * rich-markdown editor, whose document model has no source lines). + * + * Kept ASCII so the label survives being inserted as an inline mention token in + * the chat input. Labels must be unique across a message's chips — the caller + * resolves collisions through `uniqueContextLabel`. */ export function buildFileSelectionLabel( fileName: string, startLine?: number, - endLine?: number, - key?: string + endLine?: number ): string { - const suffix = key ? ` #${key}` : '' - if (!startLine) return `${fileName}${suffix}` + if (!startLine) return fileName const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}` - return `${fileName}:${range}${suffix}` -} - -/** - * Short, deterministic key for a set of ids — same ids (any order) yield the - * same key. Used to disambiguate table-selection labels so two distinct - * selections of the same size don't collapse to one chip (chips are keyed by - * their `@label`, and a collision would drop the second context). - */ -export function selectionKey(ids: string[]): string { - const joined = [...ids].sort().join(',') - let hash = 0 - for (let i = 0; i < joined.length; i++) { - hash = (Math.imul(hash, 31) + joined.charCodeAt(i)) | 0 - } - return (hash >>> 0).toString(36) + return `${fileName}:${range}` } /** - * Builds the chip label for a table selection, e.g. `Sales (5 rows #k3f9)` or, - * for a cell range, `Sales (5 rows, 3 cols #k3f9)`. The trailing `key` (from - * {@link selectionKey}) keeps distinct same-size selections from sharing a - * label. ASCII-only for the same reason as {@link buildFileSelectionLabel}. + * Builds the chip label for a table selection, e.g. `Sales (5 rows)` or, for a + * cell range, `Sales (5 rows, 3 cols)`. ASCII-only, and collision-resolved by + * the caller, for the same reasons as {@link buildFileSelectionLabel}. */ export function buildTableSelectionLabel( tableName: string, rowCount: number, - columnCount?: number, - key?: string + columnCount?: number ): string { const rows = `${rowCount} ${rowCount === 1 ? 'row' : 'rows'}` - const suffix = key ? ` #${key}` : '' - if (!columnCount) return `${tableName} (${rows}${suffix})` + if (!columnCount) return `${tableName} (${rows})` const cols = `${columnCount} ${columnCount === 1 ? 'col' : 'cols'}` - return `${tableName} (${rows}, ${cols}${suffix})` -} - -/** - * Recovers the bare file name from a {@link buildFileSelectionLabel} label by - * stripping the trailing ` #key` disambiguator (when present) and the - * `:line` / `:start-end` range. Co-located with the builder so the two formats - * can't drift apart. Used to title the resource tab (the whole file) rather than - * the selection. - */ -export function fileNameFromSelectionLabel(label: string): string { - return label.replace(/ #[0-9a-z]+$/, '').replace(/:\d+(?:-\d+)?$/, '') -} - -/** - * Recovers the bare table name from a {@link buildTableSelectionLabel} label by - * stripping the trailing ` (N rows[, M cols])` suffix. Co-located with the - * builder for the same reason as {@link fileNameFromSelectionLabel}. - */ -export function tableNameFromSelectionLabel(label: string): string { - return label.replace(/\s*\(\d+ rows?(?:, \d+ cols?)?(?: #[0-9a-z]+)?\)$/, '') + return `${tableName} (${rows}, ${cols})` } diff --git a/apps/sim/lib/core/utils/browser-storage.test.ts b/apps/sim/lib/core/utils/browser-storage.test.ts index ff6cdd5b2e8..60304df5063 100644 --- a/apps/sim/lib/core/utils/browser-storage.test.ts +++ b/apps/sim/lib/core/utils/browser-storage.test.ts @@ -7,6 +7,11 @@ import type { ChatContext } from '@/stores/panel' const WS = 'ws-1' +/** A chip-only handoff context — the highlight-to-chat payload. */ +function chipContext(): ChatContext { + return { kind: 'file_selection', fileId: 'f1', fileName: 'a.md', label: 'a.md', text: 'a' } +} + describe('MothershipHandoffStorage', () => { beforeEach(() => { localStorage.clear() @@ -26,8 +31,10 @@ describe('MothershipHandoffStorage', () => { expect(MothershipHandoffStorage.consume(WS)).toBeNull() }) - it('refuses to store without a message or workspace', () => { + it('refuses to store without a workspace, or with neither a message nor a context', () => { expect(MothershipHandoffStorage.store({ message: ' ' }, WS)).toBe(false) + expect(MothershipHandoffStorage.store({ contexts: [] }, WS)).toBe(false) + expect(MothershipHandoffStorage.store({}, WS)).toBe(false) expect(MothershipHandoffStorage.store({ message: 'fix it' }, '')).toBe(false) expect(MothershipHandoffStorage.consume(WS)).toBeNull() }) @@ -40,7 +47,49 @@ describe('MothershipHandoffStorage', () => { expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).not.toBeNull() // The owning workspace still consumes it. - expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: undefined }) + expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: [] }) + }) + + it('stores a chip-only handoff (no message) and returns it without one', () => { + const contexts: ChatContext[] = [ + { + kind: 'file_selection', + fileId: 'f1', + fileName: 'notes.md', + label: 'notes.md:2-4', + text: 'passage', + }, + ] + expect(MothershipHandoffStorage.store({ contexts }, WS)).toBe(true) + + expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts }) + }) + + it('accumulates chip-only handoffs so a second add before navigation is not dropped', () => { + const first = chipContext() + const second: ChatContext = { + kind: 'table_selection', + tableId: 't1', + tableName: 'T', + label: 'T (1 row)', + rowIds: ['r'], + } + MothershipHandoffStorage.store({ contexts: [first] }, WS) + MothershipHandoffStorage.store({ contexts: [second] }, WS) + + expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts: [first, second] }) + }) + + it('does not accumulate chips onto a message handoff, or across workspaces', () => { + const chip = chipContext() + MothershipHandoffStorage.store({ contexts: [chip] }, WS) + // A message handoff replaces rather than inheriting the pending chips. + MothershipHandoffStorage.store({ message: 'fix it' }, WS) + expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: [] }) + + MothershipHandoffStorage.store({ contexts: [chip] }, WS) + MothershipHandoffStorage.store({ contexts: [chip] }, 'ws-other') + expect(MothershipHandoffStorage.consume('ws-other')).toEqual({ contexts: [chip] }) }) it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => { diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index 981036748c5..642e5ec514e 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -105,7 +105,6 @@ export const STORAGE_KEYS = { LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed', WORKSPACE_RECENCY: 'sim_workspace_recency', MOTHERSHIP_HANDOFF: 'sim_mothership_handoff', - MOTHERSHIP_PENDING_CONTEXTS: 'sim_mothership_pending_contexts', } as const export class WorkspaceRecencyStorage { @@ -301,17 +300,28 @@ export class LandingWorkflowSeedStorage { } export interface MothershipHandoff { - /** The message to auto-send to Chat once the home surface mounts. */ - message: string + /** + * Message to auto-send once the home surface mounts. Omit for a chip-only + * handoff, which seeds `contexts` into the input and waits for the user. + */ + message?: string /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ contexts?: ChatContext[] } +interface StoredHandoff extends MothershipHandoff { + workspaceId?: string + timestamp?: number +} + /** - * One-shot handoff that seeds an auto-sent Chat (mothership) message when the - * user is routed to the workspace home from elsewhere in the app — e.g. the - * "Troubleshoot in Chat" action on an errored log, which tags the failed run - * and asks Sim to fix it. + * One-shot handoff that seeds Chat (mothership) when the user is routed to the + * workspace home from elsewhere in the app. Two shapes share this slot: + * + * - **With a message** — auto-sent on mount, e.g. the "Troubleshoot in Chat" + * action on an errored log, which tags the failed run and asks Sim to fix it. + * - **Chips only** — the highlight-to-chat action on the standalone Files and + * Tables pages, which attaches reference chips and sends nothing. * * The home surface consumes this exactly once on mount. A short max-age guards * against a stale handoff firing on a later, unrelated visit, and `consume` @@ -321,24 +331,37 @@ export class MothershipHandoffStorage { private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF /** - * Store a handoff to be auto-sent on the next home-surface mount, scoped to - * the workspace it targets so a different workspace never claims it. - * @returns True if stored, false when the message or workspace is empty. + * Store a handoff for the next home-surface mount, scoped to the workspace it + * targets so a different workspace never claims it. Chip-only handoffs + * accumulate — "Add to chat" can fire twice before the route swap completes, + * and the second write must not drop the first. + * @returns True if stored, false when the workspace is empty or the handoff + * carries neither a message nor a context. */ static store(handoff: MothershipHandoff, workspaceId: string): boolean { - const message = handoff.message.trim() - if (!message || !workspaceId) { + const message = handoff.message?.trim() + const contexts = handoff.contexts ?? [] + if (!workspaceId || (!message && contexts.length === 0)) { return false } return BrowserStorage.setItem(MothershipHandoffStorage.KEY, { - message, - contexts: handoff.contexts, + ...(message ? { message } : {}), + contexts: message + ? contexts + : [...MothershipHandoffStorage.pendingContexts(workspaceId), ...contexts], workspaceId, timestamp: Date.now(), }) } + /** Contexts of an un-consumed chip-only handoff for `workspaceId`, else empty. */ + private static pendingContexts(workspaceId: string): ChatContext[] { + const data = BrowserStorage.getItem(MothershipHandoffStorage.KEY, null) + if (!data || data.message || data.workspaceId !== workspaceId) return [] + return Array.isArray(data.contexts) ? data.contexts : [] + } + /** * Retrieve and consume the stored handoff for `workspaceId`. A handoff owned * by a different workspace is left untouched for its owner — its tagged run @@ -348,12 +371,7 @@ export class MothershipHandoffStorage { * @param maxAge - Maximum age in milliseconds (default: 60 seconds) */ static consume(workspaceId: string, maxAge: number = 60 * 1000): MothershipHandoff | null { - const data = BrowserStorage.getItem<{ - message?: string - contexts?: ChatContext[] - workspaceId?: string - timestamp?: number - } | null>(MothershipHandoffStorage.KEY, null) + const data = BrowserStorage.getItem(MothershipHandoffStorage.KEY, null) if (!data) { return null @@ -365,75 +383,20 @@ export class MothershipHandoffStorage { MothershipHandoffStorage.clear() + const contexts = Array.isArray(data.contexts) ? data.contexts : [] if ( !data.workspaceId || - !data.message || + (!data.message && contexts.length === 0) || !data.timestamp || Date.now() - data.timestamp > maxAge ) { return null } - return { message: data.message, contexts: data.contexts } + return { ...(data.message ? { message: data.message } : {}), contexts } } static clear(): boolean { return BrowserStorage.removeItem(MothershipHandoffStorage.KEY) } } - -interface PendingContextsEntry { - contexts: ChatContext[] - workspaceId: string - timestamp: number -} - -/** - * Accumulates context chips to seed into the Chat input when it next mounts, - * used by the highlight-to-chat action when no chat is currently listening - * (e.g. the user is on the standalone Files/Tables page). Unlike - * {@link MothershipHandoffStorage}, this never auto-sends — it only pre-fills - * the input with reference chips, matching the passive "add to chat" behavior. - * Multiple adds accumulate; `consume` drains and clears the list. - */ -export class MothershipPendingContextStorage { - private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_PENDING_CONTEXTS - - /** Appends a context for the workspace, replacing any entry from another workspace. */ - static store(context: ChatContext, workspaceId: string): boolean { - if (!workspaceId) return false - const existing = BrowserStorage.getItem( - MothershipPendingContextStorage.KEY, - null - ) - const contexts = - existing && existing.workspaceId === workspaceId ? [...existing.contexts, context] : [context] - return BrowserStorage.setItem(MothershipPendingContextStorage.KEY, { - contexts, - workspaceId, - timestamp: Date.now(), - }) - } - - /** - * Retrieves and clears the pending contexts for `workspaceId`. Entries owned - * by another workspace are left untouched; stale entries past `maxAge` are - * dropped. - * @param maxAge - Maximum age in milliseconds (default: 5 minutes) - */ - static consume(workspaceId: string, maxAge: number = 5 * 60 * 1000): ChatContext[] { - const data = BrowserStorage.getItem( - MothershipPendingContextStorage.KEY, - null - ) - if (!data) return [] - if (data.workspaceId !== workspaceId) return [] - MothershipPendingContextStorage.clear() - if (!data.timestamp || Date.now() - data.timestamp > maxAge) return [] - return Array.isArray(data.contexts) ? data.contexts : [] - } - - static clear(): boolean { - return BrowserStorage.removeItem(MothershipPendingContextStorage.KEY) - } -} diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 50b13070028..b086624dadc 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -3,6 +3,16 @@ import type { ChatContext } from '@/stores/panel' const logger = createLogger('MothershipEvents') +/** + * Dispatches a cancelable window event and reports whether a mounted consumer + * claimed it. Consumers claim by calling `preventDefault`, which makes + * `dispatchEvent` return `false` — so an unclaimed event is one no listener + * handled, and the producer falls back to persisting a handoff. + */ +function dispatchClaimable(name: string, detail: T): boolean { + return !window.dispatchEvent(new CustomEvent(name, { detail, cancelable: true })) +} + /** * Custom-event name used to send a user message to the Mothership chat. * The mothership host components (workspace home, workflow panel) listen @@ -19,9 +29,7 @@ export interface MothershipSendMessageDetail { /** * Dispatches a message to a mounted Mothership chat. Producers (terminal block * errors, console copilot actions, toast actions, the log "Troubleshoot in - * Chat" action) call this; consumers listen for - * {@link MOTHERSHIP_SEND_MESSAGE_EVENT} on `window` and `preventDefault` to - * claim it. + * Chat" action) call this. * * @returns `true` when a mounted host consumed the message, `false` when none * was listening — callers that can fall back (e.g. cross-route navigation) use @@ -33,16 +41,11 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) logger.warn('sendMothershipMessage called with empty message') return false } - const consumed = !window.dispatchEvent( - new CustomEvent(MOTHERSHIP_SEND_MESSAGE_EVENT, { - detail: { message: trimmed, contexts }, - cancelable: true, - }) - ) - logger.info('Dispatched mothership message event', { - messageLength: trimmed.length, - consumed, + const consumed = dispatchClaimable(MOTHERSHIP_SEND_MESSAGE_EVENT, { + message: trimmed, + contexts, }) + logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed }) return consumed } @@ -50,6 +53,10 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) * Custom-event name used to attach a context chip to the Mothership chat input * WITHOUT sending a message. The mounted chat input listens for this and inserts * the chip, leaving the user to type their prompt and send when ready. + * + * Kept separate from {@link MOTHERSHIP_SEND_MESSAGE_EVENT} because the consumer + * differs: "send now" is claimed by the chat host, "attach a chip" by the input. + * Folding both into one event would make two listeners race to claim it. */ export const MOTHERSHIP_ADD_CONTEXT_EVENT = 'mothership-add-context' @@ -60,21 +67,16 @@ export interface MothershipAddContextDetail { /** * Dispatches a passive "add this context chip" request to a mounted Mothership - * chat input. Producers (the highlight-to-chat action in the file/table viewers) - * call this; the mounted input listens for {@link MOTHERSHIP_ADD_CONTEXT_EVENT} - * and `preventDefault`s to claim it. + * chat input — the highlight-to-chat action in the file and table viewers. * * @returns `true` when a mounted input consumed it, `false` when none was - * listening — callers fall back to persisting the context for the next chat - * mount (see `MothershipPendingContextStorage`). + * listening — callers fall back to persisting a chip-only + * {@link MothershipHandoff} for the next chat mount. */ export function addMothershipContext(context: ChatContext): boolean { - const consumed = !window.dispatchEvent( - new CustomEvent(MOTHERSHIP_ADD_CONTEXT_EVENT, { - detail: { context }, - cancelable: true, - }) - ) + const consumed = dispatchClaimable(MOTHERSHIP_ADD_CONTEXT_EVENT, { + context, + }) logger.info('Dispatched mothership add-context event', { kind: context.kind, consumed }) return consumed } diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 815ad7a06a6..2305f5ed39c 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -28,6 +28,12 @@ export type ChatContext = kind: 'table_selection' tableId: string label: string + /** + * Name of the table the selection came from. Carried explicitly rather + * than parsed back out of `label`, which is a display string the input may + * rewrite to keep chip tokens unique. + */ + tableName: string /** Ids of the selected rows. Always present (materialized from the grid selection). */ rowIds: string[] /** @@ -41,9 +47,19 @@ export type ChatContext = kind: 'file_selection' fileId: string label: string - /** The literal selected text, carried inline so the agent sees the exact passage. */ + /** Name of the file the selection came from. See `tableName` above. */ + fileName: string + /** + * The literal selected text. Carried inline rather than re-read + * server-side because the editor may hold unsaved changes — re-reading + * would hand the agent different bytes than the user highlighted. + */ text: string - /** 1-based inclusive line range of the selection, when the source has lines. */ + /** + * 1-based inclusive line range, present only when the source has real + * line numbers (Monaco). The rich-markdown editor's document model has no + * source lines, so it omits these rather than approximating them. + */ startLine?: number endLine?: number } From a80f2e2f3c7a97aea7b452daffb5b31f8621f6fc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 21:55:19 -0700 Subject: [PATCH 05/26] refactor(chat): tighten table copy fallback and helper placement - writeLoadedRowsWithChip now requires a chip to carry; with none it falls through to the canonical paged path (preserving its row loading and truncation notice) instead of doing a bare synchronous write. - Reuse selectedColumnIds in the context-menu memo. - Restore resolveTableSelectionResource's TSDoc, orphaned when renderTableCell was extracted between the doc and its function. --- .../components/table-grid/table-grid.tsx | 27 ++++++++++--------- apps/sim/lib/copilot/chat/process-contents.ts | 13 ++++----- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 843d47002af..18497148c55 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -360,10 +360,13 @@ function buildTableSelectionContext(opts: { /** * Copies `rows` synchronously on the copy event so a chat-selection chip can - * ride alongside the tab-separated text. Only viable when every selected row is - * already loaded and the set is within the chip cap — otherwise the caller falls - * back to the paged `writeSelectionToClipboard`, whose async Clipboard API write - * replaces the whole clipboard and so cannot carry a custom MIME type. + * ride alongside the tab-separated text. The paged `writeSelectionToClipboard` + * cannot do this: its async Clipboard API write replaces the whole clipboard and + * so cannot carry a custom MIME type. + * + * Taking this path requires a chip to carry AND every selected row already + * loaded and within the chip cap; otherwise the canonical paged path handles the + * copy, including its row loading and truncation notice. * * @returns True when it handled the copy, false to fall through to the paged path. */ @@ -374,13 +377,15 @@ function writeLoadedRowsWithChip(opts: { buildCells: (row: TableRowType) => string[] context: ChatContext | null }): boolean { - const { rows } = opts - if (!opts.allLoaded || rows.length === 0 || rows.length > MAX_TABLE_SELECTION_ROWS) return false + const { rows, context } = opts + if (!context || !opts.allLoaded || rows.length === 0 || rows.length > MAX_TABLE_SELECTION_ROWS) { + return false + } opts.clipboardData?.setData( 'text/plain', rows.map((row) => opts.buildCells(row).join('\t')).join('\n') ) - if (opts.context) attachSelectionContextToClipboard(opts.clipboardData, opts.context) + attachSelectionContextToClipboard(opts.clipboardData, context) toast.success(`Copied ${rows.length} ${rows.length === 1 ? 'row' : 'rows'}`) return true } @@ -3836,11 +3841,9 @@ export function TableGrid({ if (!sel) return undefined const contextRowArrayIndex = rows.findIndex((r) => r.id === contextMenu.row!.id) if (contextRowArrayIndex < sel.startRow || contextRowArrayIndex > sel.endRow) return undefined - const ids: string[] = [] - for (let c = sel.startCol; c <= sel.endCol; c++) { - const col = displayColumns[c] - if (col) ids.push(getColumnId(col)) - } + // Collapsed here too (not only in buildTableSelectionContext) because this + // also decides whether the menu item reads "cell range" or "rows". + const ids = selectedColumnIds(displayColumns, sel) return ids.length > 0 && ids.length < displayColumns.length ? ids : undefined }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows, displayColumns]) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 1765ba367ff..507beb8eddc 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -930,12 +930,6 @@ async function resolveFileSelectionResource( } } -/** - * Resolves a table selection into an inline markdown table. Rows are re-fetched - * by id from the DB (never trusting client-sent cell values); when `columnIds` - * is present the projection is narrowed to that cell range, otherwise every - * column is included. - */ /** Renders one cell for a markdown table row, escaping the delimiters. */ function renderTableCell(value: unknown): string { if (value === null || value === undefined) return '' @@ -943,6 +937,13 @@ function renderTableCell(value: unknown): string { return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ') } +/** + * Resolves a table selection into an inline markdown table. Rows are re-fetched + * by id from the DB (never trusting client-sent cell values); when `columnIds` + * is present the projection is narrowed to that cell range, otherwise every + * column is included. Output is bounded by + * {@link MAX_TABLE_SELECTION_CONTENT_LENGTH}, not just the row and column caps. + */ async function resolveTableSelectionResource( tableId: string, workspaceId: string, From 8455da9c71e5dc2bb2925be1f4da671112157657 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 22:32:14 -0700 Subject: [PATCH 06/26] fix(chat): apply chip handoffs as one batch; widen table copy chip path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: a multi-context chip handoff dispatched one event per context, and insertContextChip resolved label collisions against selectedContexts read through a ref that only refreshes on render. Each dispatch therefore saw the same stale list, so a second same-label selection was never ordinalized and addContext dropped it while its @token still landed in the text. The event now carries the whole batch and insertContextChips threads each resolved context forward as it goes. Greptile: an explicit multi-row ('some') selection no longer requires every selected row to be loaded before taking the chip-carrying sync path. That gate assumed the paged fall-through would copy more, but its loadRows returns rowsRef.current unchanged for 'some' — the same rows, minus the chip. Renamed the parameter to to say what it actually gates on. Remaining chip-less cases are inherent to the async Clipboard API, which replaces the whole clipboard and cannot hold a custom MIME: a filtered select-all (must page in more rows) and selections past the 500-row chip cap. 'Add to chat' covers both — it is not gesture-bound and drains to the cap. --- .../prompt-editor/use-prompt-editor.ts | 33 ++++++++++++------- .../home/components/user-input/user-input.tsx | 4 +-- .../app/workspace/[workspaceId]/home/home.tsx | 9 +++-- .../components/table-grid/table-grid.tsx | 26 +++++++++------ .../components/user-input/utils.test.ts | 18 ++++++++++ apps/sim/hooks/use-add-to-chat.ts | 4 +-- apps/sim/lib/mothership/events.ts | 18 ++++++---- 7 files changed, 75 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index d709948d1f7..318c4b26157 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -468,27 +468,36 @@ export function usePromptEditor({ ) /** - * Inserts a context as an `@label` chip at the caret and registers it. Unlike + * Inserts contexts as `@label` chips at the caret and registers them. Unlike * the menu-driven inserts, this is triggered programmatically (the * highlight-to-chat action in the file/table viewers) rather than by a typed * `@`/`/` trigger, so it always inserts at the current cursor position. + * + * Takes the whole batch so label collisions resolve against the chips added + * earlier in the same call: `selectedContexts` is React state read through a + * ref, so it does not reflect an add until the next render. */ - const insertContextChip = useCallback( - (context: ChatContext) => { - const prepared = prepareContextForInsert( - context, - contextManagementRef.current.selectedContexts - ) - if (!prepared) { + const insertContextChips = useCallback( + (contexts: ChatContext[]) => { + let attached = contextManagementRef.current.selectedContexts + const prepared: ChatContext[] = [] + for (const context of contexts) { + const next = prepareContextForInsert(context, attached) + if (!next) continue + prepared.push(next) + attached = [...attached, next] + } + if (prepared.length === 0) { textareaRef.current?.focus() return } + const textarea = textareaRef.current if (textarea) { const currentValue = valueRef.current const insertAt = textarea.selectionStart ?? currentValue.length const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) - const insertText = `${needsSpaceBefore ? ' ' : ''}@${prepared.label} ` + const insertText = `${needsSpaceBefore ? ' ' : ''}${prepared.map((c) => `@${c.label} `).join('')}` const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}` pendingCursorRef.current = insertAt + insertText.length @@ -496,7 +505,7 @@ export function usePromptEditor({ setValueState(newValue) } - addContextNotified(prepared) + for (const context of prepared) addContextNotified(context) }, [textareaRef, addContextNotified] ) @@ -1101,8 +1110,8 @@ export function usePromptEditor({ clear, focusAtEnd, insertResources, - /** Inserts a context as an `@label` chip at the caret (highlight-to-chat). */ - insertContextChip, + /** Inserts contexts as `@label` chips at the caret (highlight-to-chat). */ + insertContextChips, insertSlashTrigger, openResourceMenu, /** The editor's textarea element — focus management, caret restore. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 8462c96409c..b35c65fe810 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -185,9 +185,9 @@ const UserInputImpl = forwardRef(function UserI useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail - if (!detail?.context) return + if (!detail?.contexts?.length) return e.preventDefault() - editorRef.current.insertContextChip(detail.context) + editorRef.current.insertContextChips(detail.contexts) textareaRef.current?.focus() } window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index a6f860924e6..6113f9d4f47 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -27,7 +27,7 @@ import { MothershipHandoffStorage, } from '@/lib/core/utils/browser-storage' import { - addMothershipContext, + addMothershipContexts, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' @@ -355,10 +355,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) sendMessage(handoff.message, undefined, handoff.contexts) return } - for (const context of handoff.contexts ?? []) { - handleContextAdd(context) - addMothershipContext(context) - } + const contexts = handoff.contexts ?? [] + for (const context of contexts) handleContextAdd(context) + addMothershipContexts(contexts) // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot drain; handleContextAdd is a stable body function }, [chatId, workspaceId, sendMessage]) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 18497148c55..ff5482bef36 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -364,21 +364,24 @@ function buildTableSelectionContext(opts: { * cannot do this: its async Clipboard API write replaces the whole clipboard and * so cannot carry a custom MIME type. * - * Taking this path requires a chip to carry AND every selected row already - * loaded and within the chip cap; otherwise the canonical paged path handles the + * Taking this path requires a chip to carry, `rows` being the complete copy, and + * a set within the chip cap; otherwise the canonical paged path handles the * copy, including its row loading and truncation notice. * + * @param complete - Whether `rows` is everything this copy should contain. False + * when the paged path would load rows this caller cannot see yet, so deferring + * to it copies strictly more. * @returns True when it handled the copy, false to fall through to the paged path. */ function writeLoadedRowsWithChip(opts: { clipboardData: DataTransfer | null rows: TableRowType[] - allLoaded: boolean + complete: boolean buildCells: (row: TableRowType) => string[] context: ChatContext | null }): boolean { const { rows, context } = opts - if (!context || !opts.allLoaded || rows.length === 0 || rows.length > MAX_TABLE_SELECTION_ROWS) { + if (!context || !opts.complete || rows.length === 0 || rows.length > MAX_TABLE_SELECTION_ROWS) { return false } opts.clipboardData?.setData( @@ -3012,14 +3015,17 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - // A filtered select-all ('all') covers rows beyond the loaded page, so - // only an explicit multi-row selection can take the chip-carrying path. + // Only an explicit multi-row selection can carry the chip: a filtered + // select-all ('all') pages in rows beyond those loaded, which the async + // path must fetch. For 'some' the fall-through re-reads the same loaded + // rows (see its `loadRows`), so it never copies more than this does — + // the selection is complete here even when some ids aren't loaded yet. if (rowSel.kind === 'some') { const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id)) const handled = writeLoadedRowsWithChip({ clipboardData: e.clipboardData, rows: selectedRows, - allLoaded: selectedRows.length === rowSel.ids.size, + complete: true, buildCells: (row) => cols.map((col) => cellToText(row.data[col.key], col)), context: buildTableSelectionContext({ tableId, @@ -3059,12 +3065,12 @@ export function TableGrid({ } const colByKey = new Map(cols.map((c) => [c.key, c])) - // A column-header selection spans every row, so the chip-carrying path - // only applies once the whole table is loaded. + // A column-header selection spans every row, and its fall-through pages + // in the rest — so the chip path applies only once all of them are here. const handled = writeLoadedRowsWithChip({ clipboardData: e.clipboardData, rows: currentRows, - allLoaded: currentRows.length >= selectAllTotalRef.current, + complete: currentRows.length >= selectAllTotalRef.current, buildCells: (row) => colNames.map((name) => cellToText(row.data[name], colByKey.get(name))), context: buildTableSelectionContext({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts index 54a4922195c..6ac6272c751 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts @@ -78,4 +78,22 @@ describe('prepareContextForInsert', () => { expect(prepareContextForInsert(context, [])).toEqual(context) }) + + it('ordinalizes within a batch when the caller threads each result forward', () => { + // How insertContextChips applies a multi-context handoff: `selectedContexts` + // is React state read through a ref and does not reflect an add until the + // next render, so the batch must accumulate locally or the second colliding + // chip is silently dropped. + const batch = [tableSelection(), tableSelection({ rowIds: ['r7', 'r8', 'r9'] })] + let attached: ChatContext[] = [] + const prepared: ChatContext[] = [] + for (const context of batch) { + const next = prepareContextForInsert(context, attached) + if (!next) continue + prepared.push(next) + attached = [...attached, next] + } + + expect(prepared.map((c) => c.label)).toEqual(['Sales (3 rows)', 'Sales (3 rows) (2)']) + }) }) diff --git a/apps/sim/hooks/use-add-to-chat.ts b/apps/sim/hooks/use-add-to-chat.ts index 948835da8ba..b94bb65b970 100644 --- a/apps/sim/hooks/use-add-to-chat.ts +++ b/apps/sim/hooks/use-add-to-chat.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react' import { useParams, useRouter } from 'next/navigation' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' -import { addMothershipContext } from '@/lib/mothership/events' +import { addMothershipContexts } from '@/lib/mothership/events' import type { ChatContext } from '@/stores/panel' /** @@ -23,7 +23,7 @@ export function useAddToChat(): (context: ChatContext) => void { return useCallback( (context: ChatContext) => { - if (addMothershipContext(context)) return + if (addMothershipContexts([context])) return if (!workspaceId) return if (MothershipHandoffStorage.store({ contexts: [context] }, workspaceId)) { router.push(`/workspace/${workspaceId}/home`) diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index b086624dadc..a4dc7d4c5b7 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -61,22 +61,28 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) export const MOTHERSHIP_ADD_CONTEXT_EVENT = 'mothership-add-context' export interface MothershipAddContextDetail { - /** The context to attach as a chip in the input. */ - context: ChatContext + /** The contexts to attach as chips, in insertion order. */ + contexts: ChatContext[] } /** - * Dispatches a passive "add this context chip" request to a mounted Mothership + * Dispatches a passive "add these context chips" request to a mounted Mothership * chat input — the highlight-to-chat action in the file and table viewers. * + * Carries the whole batch in one event rather than one event per context: the + * input resolves label collisions against its current chips, and that list only + * refreshes on re-render, so consecutive synchronous dispatches would each see + * the same stale list and drop colliding chips. + * * @returns `true` when a mounted input consumed it, `false` when none was * listening — callers fall back to persisting a chip-only * {@link MothershipHandoff} for the next chat mount. */ -export function addMothershipContext(context: ChatContext): boolean { +export function addMothershipContexts(contexts: ChatContext[]): boolean { + if (contexts.length === 0) return false const consumed = dispatchClaimable(MOTHERSHIP_ADD_CONTEXT_EVENT, { - context, + contexts, }) - logger.info('Dispatched mothership add-context event', { kind: context.kind, consumed }) + logger.info('Dispatched mothership add-context event', { count: contexts.length, consumed }) return consumed } From 598791e565943d2193f178175dcce01efc7c3211 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 22:44:03 -0700 Subject: [PATCH 07/26] fix(chat): distinguish a line-less file-selection label from the whole file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: a rich-markdown selection labelled itself with the bare file name, which is exactly the whole-file chip's label. Menu-driven inserts reject any context whose label is already taken (isContextAlreadySelected closes the menu silently), so once a markdown selection was attached, mentioning that same file was quietly dropped. One-way: the reverse order works because programmatic inserts ordinalize through prepareContextForInsert. Fallen out of dropping the fabricated line range from this editor — with no range left, the label collapsed onto the file name. Fixed at the single source: buildFileSelectionLabel now returns 'notes.md (selection)' when there is no line range, so it stays honest about location while remaining distinguishable. --- apps/sim/lib/copilot/chat/selection-context.test.ts | 7 +++++-- apps/sim/lib/copilot/chat/selection-context.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/copilot/chat/selection-context.test.ts b/apps/sim/lib/copilot/chat/selection-context.test.ts index 6df3d76b5db..4f88dfb03b4 100644 --- a/apps/sim/lib/copilot/chat/selection-context.test.ts +++ b/apps/sim/lib/copilot/chat/selection-context.test.ts @@ -22,8 +22,11 @@ describe('buildFileSelectionLabel', () => { expect(buildFileSelectionLabel('notes.md', 12)).toBe('notes.md:12') }) - it('falls back to just the file name when the source has no line numbers', () => { - expect(buildFileSelectionLabel('notes.md')).toBe('notes.md') + it('marks a line-less selection so it cannot collide with the whole-file chip', () => { + // A bare 'notes.md' would equal the whole-file chip's label, and menu inserts + // silently reject an already-taken label — blocking the file's own mention. + expect(buildFileSelectionLabel('notes.md')).toBe('notes.md (selection)') + expect(buildFileSelectionLabel('notes.md')).not.toBe('notes.md') }) }) diff --git a/apps/sim/lib/copilot/chat/selection-context.ts b/apps/sim/lib/copilot/chat/selection-context.ts index f655fe8140f..b436d36ae3f 100644 --- a/apps/sim/lib/copilot/chat/selection-context.ts +++ b/apps/sim/lib/copilot/chat/selection-context.ts @@ -44,9 +44,14 @@ export function truncateSelectionText(text: string): string { } /** - * Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40`, - * `notes.md:12`, or just `notes.md` when the source has no line numbers (the - * rich-markdown editor, whose document model has no source lines). + * Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40` or + * `notes.md:12`. Without a line range — the rich-markdown editor, whose document + * model has no source lines — it falls back to `notes.md (selection)`. + * + * That suffix is load-bearing, not decoration: a bare file name would be + * identical to the whole-file `@notes.md` chip's label, and menu-driven inserts + * reject any context whose label is already taken (`isContextAlreadySelected`). + * A markdown selection would then silently block mentioning its own file. * * Kept ASCII so the label survives being inserted as an inline mention token in * the chat input. Labels must be unique across a message's chips — the caller @@ -57,7 +62,7 @@ export function buildFileSelectionLabel( startLine?: number, endLine?: number ): string { - if (!startLine) return fileName + if (!startLine) return `${fileName} (selection)` const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}` return `${fileName}:${range}` } From 7d3d58d47e3e1a6bb93a1ce500d96339341ad7e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 22:53:03 -0700 Subject: [PATCH 08/26] fix(chat): don't revive an aged-out chip handoff when accumulating Cursor Bugbot: pendingContexts merged a prior chip-only handoff without checking its age, while store stamps a fresh timestamp on every write. An abandoned handoff that had already passed max-age would therefore ride along on the next 'Add to chat' and fire on the following navigation as if current. Introduced by the accumulation behavior added earlier in this PR. Applied the same freshness bar consume uses, and hoisted the 60s window into a single MAX_AGE_MS constant so the two paths cannot drift. --- .../lib/core/utils/browser-storage.test.ts | 25 +++++++++++++++++++ apps/sim/lib/core/utils/browser-storage.ts | 22 +++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/core/utils/browser-storage.test.ts b/apps/sim/lib/core/utils/browser-storage.test.ts index 60304df5063..0b64ee5798a 100644 --- a/apps/sim/lib/core/utils/browser-storage.test.ts +++ b/apps/sim/lib/core/utils/browser-storage.test.ts @@ -80,6 +80,31 @@ describe('MothershipHandoffStorage', () => { expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts: [first, second] }) }) + it('does not revive chips from a handoff that already aged out', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const abandoned = chipContext() + MothershipHandoffStorage.store({ contexts: [abandoned] }, WS) + + // The user walks away; the handoff expires in place. A later "Add to chat" + // stamps a fresh timestamp, which must not carry the dead chip forward. + vi.advanceTimersByTime(61 * 1000) + const fresh: ChatContext = { + kind: 'table_selection', + tableId: 't1', + tableName: 'T', + label: 'T (1 row)', + rowIds: ['r'], + } + MothershipHandoffStorage.store({ contexts: [fresh] }, WS) + + expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts: [fresh] }) + } finally { + vi.useRealTimers() + } + }) + it('does not accumulate chips onto a message handoff, or across workspaces', () => { const chip = chipContext() MothershipHandoffStorage.store({ contexts: [chip] }, WS) diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index 642e5ec514e..af43319d717 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -330,6 +330,9 @@ interface StoredHandoff extends MothershipHandoff { export class MothershipHandoffStorage { private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF + /** How long a stored handoff stays eligible to fire, in milliseconds. */ + static readonly MAX_AGE_MS = 60 * 1000 + /** * Store a handoff for the next home-surface mount, scoped to the workspace it * targets so a different workspace never claims it. Chip-only handoffs @@ -355,10 +358,20 @@ export class MothershipHandoffStorage { }) } - /** Contexts of an un-consumed chip-only handoff for `workspaceId`, else empty. */ + /** + * Contexts of an un-consumed chip-only handoff for `workspaceId`, else empty. + * + * Applies the same freshness bar as {@link consume}: accumulating carries the + * old contexts onto a write that stamps a new `timestamp`, so without this an + * abandoned handoff that had already aged out would ride along on the next + * "Add to chat" and reappear as if it were current. + */ private static pendingContexts(workspaceId: string): ChatContext[] { const data = BrowserStorage.getItem(MothershipHandoffStorage.KEY, null) if (!data || data.message || data.workspaceId !== workspaceId) return [] + if (!data.timestamp || Date.now() - data.timestamp > MothershipHandoffStorage.MAX_AGE_MS) { + return [] + } return Array.isArray(data.contexts) ? data.contexts : [] } @@ -368,9 +381,12 @@ export class MothershipHandoffStorage { * only resolves in its own workspace, so misfiring it elsewhere would drop the * context. The owner (and any legacy/corrupt entry) is tombstoned via `clear` * before the validity/expiry checks so it fires at most once and never lingers. - * @param maxAge - Maximum age in milliseconds (default: 60 seconds) + * @param maxAge - Maximum age in milliseconds (default: {@link MAX_AGE_MS}) */ - static consume(workspaceId: string, maxAge: number = 60 * 1000): MothershipHandoff | null { + static consume( + workspaceId: string, + maxAge: number = MothershipHandoffStorage.MAX_AGE_MS + ): MothershipHandoff | null { const data = BrowserStorage.getItem(MothershipHandoffStorage.KEY, null) if (!data) { From f82c44e0d404e55129491e07b49578de2bea5f27 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 23:05:09 -0700 Subject: [PATCH 09/26] fix(chat): reference every selected row in a table chip, not just loaded ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: for a 'some' row selection the chip's rowIds came from the loaded-page intersection (currentRows filtered by the selection) rather than rowSel.ids. The chip carries ids and the server re-fetches them via getRowsByIds, so a selected row that simply had not been paged into the grid was silently dropped from the agent's context — select 600 rows with 200 loaded and the agent saw 200. Add to chat had the same loaded-only narrowing through contextMenuRowIds. Both now send the full selection, still bounded by MAX_TABLE_SELECTION_ROWS. Only the pasted text stays limited to loaded rows, which is inherent — there are no cell values to serialize for a row that has not been fetched. --- .../components/table-grid/table-grid.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index ff5482bef36..df37df7c887 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -3031,7 +3031,10 @@ export function TableGrid({ tableId, tableName: tableNameRef.current, totalColumnCount: cols.length, - rowIds: selectedRows.map((row) => row.id), + // Every selected id, not just the loaded page: the chip carries + // ids and the server re-fetches them, so an unloaded row still + // reaches the agent. Only the pasted text is limited to `rows`. + rowIds: [...rowSel.ids], }), }) if (handled) return @@ -3860,7 +3863,16 @@ export function TableGrid({ // `contextMenuRowIds` reflects; drain up to the cap so the chip references as // many rows as it can carry (bounded by MAX_TABLE_SELECTION_ROWS) instead of a // silent loaded-only subset — mirroring how the copy path loads before writing. - let sourceRowIds = contextMenuRowIds + // A gutter selection can extend past the loaded page, and `contextMenuRowIds` + // is the loaded intersection. The chip references ids the server re-fetches, + // so send the whole selection rather than whichever rows happen to be paged in. + const gutterSelection = rowSelectionRef.current + let sourceRowIds = + gutterSelection.kind === 'some' && + contextMenu.row && + rowSelectionIncludes(gutterSelection, contextMenu.row.id) + ? [...gutterSelection.ids] + : contextMenuRowIds if (contextMenuIsSelectAll || isColumnSelectionRef.current) { try { const { rows: loaded } = await ensureRowsLoadedUpToRef.current(MAX_TABLE_SELECTION_ROWS) @@ -3889,6 +3901,7 @@ export function TableGrid({ contextMenuRowIds, contextMenuColumnIds, contextMenuIsSelectAll, + contextMenu.row, tableId, tableData?.name, displayColumns.length, From 9a1da090fe347bfdb83eeebbc460ef27880be8ba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 23:06:14 -0700 Subject: [PATCH 10/26] docs(chat): scope the table copy 'complete' comment to the text path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It read as though the whole selection were complete when ids are unloaded, which is now only true of the serialized text — the chip deliberately carries every selected id for the server to re-fetch. --- .../[tableId]/components/table-grid/table-grid.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index df37df7c887..66a16279571 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -3015,11 +3015,12 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - // Only an explicit multi-row selection can carry the chip: a filtered + // Only an explicit multi-row selection can take this path: a filtered // select-all ('all') pages in rows beyond those loaded, which the async - // path must fetch. For 'some' the fall-through re-reads the same loaded - // rows (see its `loadRows`), so it never copies more than this does — - // the selection is complete here even when some ids aren't loaded yet. + // fall-through must fetch. `complete` refers to the copied TEXT only — + // for 'some' the fall-through re-reads these same loaded rows (see its + // `loadRows`), so it can never serialize more than this does. The chip + // is not bound by that; see `rowIds` below. if (rowSel.kind === 'some') { const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id)) const handled = writeLoadedRowsWithChip({ From 231e0a09c10c7947cb79fb0a6e056cc91e8bc46a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 23:16:17 -0700 Subject: [PATCH 11/26] fix(chat): compare selection ids as sets, not sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: sameIds compared rowIds/columnIds by index, but a table selection's ids iterate in click order (they come from a Set), so the same rows picked in a different order — or reached via a cell range rather than the gutter — compared unequal. prepareContextForInsert then added a second ordinalized chip pointing at rows already referenced instead of no-opping. More reachable since the previous commit started sourcing rowIds from rowSel.ids directly, where insertion order tracks the user's clicks. --- .../copilot/components/user-input/utils.test.ts | 8 ++++++++ .../copilot/components/user-input/utils.ts | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts index 6ac6272c751..d5f03c99c44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.test.ts @@ -67,6 +67,14 @@ describe('prepareContextForInsert', () => { expect(prepareContextForInsert(other, [fileSelection()])).not.toBeNull() }) + it('treats the same rows picked in a different order as one selection', () => { + // Row ids iterate in click order, so re-picking the same rows differently + // must no-op rather than add a second chip over rows already referenced. + const reordered = tableSelection({ rowIds: ['r3', 'r1', 'r2'] }) + + expect(prepareContextForInsert(reordered, [tableSelection()])).toBeNull() + }) + it('distinguishes a cell range from the whole rows it spans', () => { const range = tableSelection({ columnIds: ['c_name'] }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index db73af9b728..635935f3428 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -193,11 +193,20 @@ type McpContext = Extract type FileSelectionContext = Extract type TableSelectionContext = Extract -/** Order-sensitive equality for two optional id lists. */ +/** + * Set equality for two optional id lists. + * + * Deliberately order-insensitive: a table selection's row ids come from a Set + * whose iteration order follows click order, and the same rows picked in a + * different order — or via a cell range rather than the gutter — are the same + * selection. Comparing by index would call those distinct and add a duplicate + * ordinalized chip pointing at rows already referenced. + */ function sameIds(a: string[] | undefined, b: string[] | undefined): boolean { if (a === b) return true if (!a || !b || a.length !== b.length) return false - return a.every((id, i) => id === b[i]) + const inA = new Set(a) + return b.every((id) => inA.has(id)) } /** From b20cfcd7a65dea0bb093bf48efe1d6087c6c42fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 10:37:22 -0700 Subject: [PATCH 12/26] fix(chat): enforce the table selection budget over the whole rendered content Cursor Bugbot: the budget subtracted only the header and divider before packing rows, then prepended the 'Selected ...' prose and the newlines afterward, so the final content could exceed MAX_TABLE_SELECTION_CONTENT_LENGTH whenever the last accepted row left less slack than the prefix needed. The cap the TSDoc promises was not actually enforced. The prior test passed while missing this: its rows were wide enough that packing stopped far short of the limit, so the boundary was never exercised. Replaced with rows sized to fill the budget almost exactly, asserting both that the content stays within the cap and that it still approaches it (so the assertion can't pass by emitting an empty table). Also capitalize Chat in the three 'Add to Chat' menu labels, matching the constitution's module naming and the existing 'Fix in Chat' / 'Troubleshoot in Chat' UI strings. --- .../file-viewer/editor-context-menu.tsx | 2 +- .../menus/bubble-menu.tsx | 2 +- .../components/context-menu/context-menu.tsx | 2 +- .../components/table-grid/table-grid.tsx | 2 +- .../lib/copilot/chat/process-contents.test.ts | 40 +++++++++++++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 30 ++++++++++---- 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx index 3f1fb5bd805..3cfd6c0e228 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx @@ -67,7 +67,7 @@ export function EditorContextMenu({ <> - Add to chat + Add to Chat diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 8036172017c..ca2acb30e0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -254,7 +254,7 @@ export function EditorBubbleMenu({ <> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 8dc84f53592..2a8272f5553 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -85,7 +85,7 @@ export function ContextMenu({ disableDuplicate = false, disableDelete = false, onAddToChat, - addToChatLabel = 'Add to chat', + addToChatLabel = 'Add to Chat', }: ContextMenuProps) { const count = selectedRowCount.toLocaleString() const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 66a16279571..e3d4e43704b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -4631,7 +4631,7 @@ export function TableGrid({ disableDuplicate={!canInsertFullRow} disableDelete={!canDeleteRow} onAddToChat={contextMenuRowIds.length > 0 ? handleAddSelectionToChat : undefined} - addToChatLabel={contextMenuColumnIds ? 'Add cell range to chat' : 'Add rows to chat'} + addToChatLabel={contextMenuColumnIds ? 'Add cell range to Chat' : 'Add rows to Chat'} /> { expect(result).toEqual([]) }) + it('keeps the whole rendered content within budget when rows pack tightly', async () => { + // Rows small enough to fill the budget almost exactly: the last accepted row + // leaves only a few characters of slack, so a budget that forgot to reserve + // the prose prefix and newlines overruns the cap here while passing on + // coarse fixtures that stop far short of the limit. + const cell = 'x'.repeat(100) + const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({ + id: `r${i}`, + data: { c_notes: cell }, + })) + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'c_notes', name: 'Notes' }] }, + }) + getRowsByIds.mockResolvedValue(rows) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + tableName: 'Sales', + label: 'Sales (500 rows)', + rowIds: rows.map((r) => r.id), + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + const [ctx] = result + expect(ctx.content.length).toBeLessThanOrEqual(MAX_TABLE_SELECTION_CONTENT_LENGTH) + // Guard against passing by emitting almost nothing — it must still be a + // real table that genuinely approaches the cap. + expect(ctx.content.length).toBeGreaterThan(MAX_TABLE_SELECTION_CONTENT_LENGTH * 0.9) + expect(ctx.content).toContain('omitted for length') + }) + it('spends a character budget across rows and reports what it omitted', async () => { // Row/column caps alone don't bound prompt cost: wide cells blow past the // budget long before MAX_TABLE_SELECTION_ROWS. diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 507beb8eddc..20c23867031 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -930,6 +930,14 @@ async function resolveFileSelectionResource( } } +/** + * Longest form the size clause can take for `total` rows — every row omitted. + * Used to reserve prefix space before the real counts are known. + */ +function worstCaseSizeClause(total: number): string { + return `${total} of ${total}, ${total} omitted for length` +} + /** Renders one cell for a markdown table row, escaping the delimiters. */ function renderTableCell(value: unknown): string { if (value === null || value === undefined) return '' @@ -971,24 +979,32 @@ async function resolveTableSelectionResource( const header = `| ${columns.map((c) => c.name).join(' | ')} |` const divider = `| ${columns.map(() => '---').join(' | ')} |` + const scope = hasColumnScope ? 'cell range' : 'rows' + const describe = (size: string) => + `Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n` - // Spend the character budget row by row: the row cap alone doesn't bound the - // prompt cost. The first row is always emitted, so a single oversized row - // still yields a table rather than an empty one. + // Spend the character budget row by row. Everything that is not a row — the + // prose, the table head, and every newline — is reserved up front, or the cap + // is silently overrun whenever the last row leaves less slack than the prefix + // needs. The reserve uses the longest form the size clause can take (every row + // omitted), since its real value isn't known until packing finishes; a few + // characters of unused slack beats overshooting the documented bound. const lines: string[] = [] - let remaining = MAX_TABLE_SELECTION_CONTENT_LENGTH - header.length - divider.length + let remaining = + MAX_TABLE_SELECTION_CONTENT_LENGTH - describe(worstCaseSizeClause(rows.length)).length for (const row of rows) { const line = `| ${columns.map((col) => renderTableCell(row.data[getColumnId(col)])).join(' | ')} |` - if (lines.length > 0 && line.length > remaining) break + // The first row always goes in, so a single oversized row still yields a + // table rather than an empty one. + if (lines.length > 0 && line.length + 1 > remaining) break lines.push(line) remaining -= line.length + 1 } const omitted = rows.length - lines.length const shown = `${lines.length} ${lines.length === 1 ? 'row' : 'rows'}` - const scope = hasColumnScope ? 'cell range' : 'rows' const size = omitted > 0 ? `${shown} of ${rows.length}, ${omitted} omitted for length` : shown - const content = `Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n${lines.join('\n')}` + const content = `${describe(size)}${lines.join('\n')}` return { type: 'table_selection', tag: label ? `@${label}` : '@', From 2e5f8f5048a01594c901c4bbd49338ba4d0ed793 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 10:55:11 -0700 Subject: [PATCH 13/26] fix(chat): don't swallow a paste whose selection chip is already attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: the selection-paste path called preventDefault before prepareContextForInsert, so when that returned null (the same selection is already a chip) the handler returned having claimed the event — no chip inserted and no text/plain pasted either. Cmd+V did nothing. preventDefault now waits until there is a chip to insert; a duplicate falls through to the plain-text paste below, which is the reasonable reading of the gesture. --- .../prompt-editor/use-prompt-editor.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 318c4b26157..1d2b2d9e4fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -929,14 +929,18 @@ export function usePromptEditor({ // custom clipboard type — paste it as a reference chip instead of plain text. // Registers via `addContext` (not the notified path) so paste never opens a // side panel, matching the portable-chip-link paste below. + // + // `preventDefault` waits until there is a chip to insert: when the selection + // is already attached there is nothing to add, and claiming the event anyway + // would swallow the keystroke entirely — no chip and no text. Falling through + // pastes the selection's plain text, which is what the user asked for. const selectionContext = readSelectionContextFromClipboard(e.clipboardData) - if (selectionContext) { + const preparedSelection = selectionContext + ? prepareContextForInsert(selectionContext, contextManagementRef.current.selectedContexts) + : null + if (preparedSelection) { e.preventDefault() - const prepared = prepareContextForInsert( - selectionContext, - contextManagementRef.current.selectedContexts - ) - if (!prepared) return + const prepared = preparedSelection const selStart = textarea.selectionStart ?? valueRef.current.length const selEnd = textarea.selectionEnd ?? selStart const needsSpaceBefore = selStart > 0 && !/\s/.test(valueRef.current.charAt(selStart - 1)) From eff18e6970eba1a87d1f39204abc618b7a09a743 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:03:08 -0700 Subject: [PATCH 14/26] fix(chat): derive the budget reserve from the same clause it reserves for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: worstCaseSizeClause built its own string that omitted the row/rows word the real clause always carries, so the reserve ran ~5 characters short and a tightly packed selection could still exceed MAX_TABLE_SELECTION_CONTENT_LENGTH. A bug in the previous fix, from duplicating the format instead of sharing it. Replaced with one sizeClause(shown, omitted) used for both the up-front reserve and the final prose, so the two cannot describe the count differently. The reserve passes (rows.length, rows.length) — max digits on both counts and the plural forced — which is an upper bound on any real clause. The earlier tight-packing test could not see this: a single cell width leaves whatever remainder it leaves, and 100 left more than 5 characters. Added a sweep over widths 60-75 that collects overflows so a failure names the width; it catches the reported bug at width 74 (20002 vs 20000). --- .../lib/copilot/chat/process-contents.test.ts | 41 +++++++++++++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 33 ++++++++------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0fab32199e1..066f4c4053b 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -531,6 +531,47 @@ describe('processContextsServer - table_selection contexts', () => { expect(ctx.content).toContain('omitted for length') }) + it('holds the cap across cell widths, including ones that pack flush to it', async () => { + // A single width can leave slack that hides an under-reserved prefix by a + // few characters. Sweeping widths lands at least one run with almost no + // remainder, which is where an off-by-N in the reserve actually shows up. + getTableById.mockResolvedValue({ + name: 'Sales', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'c_notes', name: 'Notes' }] }, + }) + + const overflows: Array<{ width: number; length: number }> = [] + for (let width = 60; width <= 75; width++) { + const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({ + id: `r${i}`, + data: { c_notes: 'x'.repeat(width) }, + })) + getRowsByIds.mockResolvedValue(rows) + + const result = await processContextsServer( + [ + { + kind: 'table_selection', + tableId: 'tbl-1', + tableName: 'Sales', + label: 'Sales (500 rows)', + rowIds: rows.map((r) => r.id), + } as ChatContext, + ], + 'user-1', + 'summarize', + 'ws-1' + ) + + const { length } = result[0].content + if (length > MAX_TABLE_SELECTION_CONTENT_LENGTH) overflows.push({ width, length }) + } + + // Collected rather than asserted per-iteration so a failure names the widths. + expect(overflows).toEqual([]) + }) + it('spends a character budget across rows and reports what it omitted', async () => { // Row/column caps alone don't bound prompt cost: wide cells blow past the // budget long before MAX_TABLE_SELECTION_ROWS. diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 20c23867031..49fda75a1c2 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -931,14 +931,8 @@ async function resolveFileSelectionResource( } /** - * Longest form the size clause can take for `total` rows — every row omitted. - * Used to reserve prefix space before the real counts are known. + * Renders one cell for a markdown table row, escaping the delimiters. */ -function worstCaseSizeClause(total: number): string { - return `${total} of ${total}, ${total} omitted for length` -} - -/** Renders one cell for a markdown table row, escaping the delimiters. */ function renderTableCell(value: unknown): string { if (value === null || value === undefined) return '' const cell = typeof value === 'string' ? value : JSON.stringify(value) @@ -983,15 +977,27 @@ async function resolveTableSelectionResource( const describe = (size: string) => `Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n` + /** + * The size clause, e.g. `5 rows` or `189 rows of 500, 311 omitted for length`. + * Used for both the up-front reserve and the final prose, so the two can never + * describe the row count differently. + */ + const sizeClause = (shownCount: number, omittedCount: number) => { + const shown = `${shownCount} ${shownCount === 1 ? 'row' : 'rows'}` + return omittedCount > 0 + ? `${shown} of ${rows.length}, ${omittedCount} omitted for length` + : shown + } + // Spend the character budget row by row. Everything that is not a row — the // prose, the table head, and every newline — is reserved up front, or the cap // is silently overrun whenever the last row leaves less slack than the prefix - // needs. The reserve uses the longest form the size clause can take (every row - // omitted), since its real value isn't known until packing finishes; a few - // characters of unused slack beats overshooting the documented bound. + // needs. The real clause isn't known until packing finishes, so reserve its + // longest form: every row shown AND every row omitted maximizes both counts + // and forces the plural. A few characters of unused slack beats overshooting. const lines: string[] = [] let remaining = - MAX_TABLE_SELECTION_CONTENT_LENGTH - describe(worstCaseSizeClause(rows.length)).length + MAX_TABLE_SELECTION_CONTENT_LENGTH - describe(sizeClause(rows.length, rows.length)).length for (const row of rows) { const line = `| ${columns.map((col) => renderTableCell(row.data[getColumnId(col)])).join(' | ')} |` // The first row always goes in, so a single oversized row still yields a @@ -1001,10 +1007,7 @@ async function resolveTableSelectionResource( remaining -= line.length + 1 } - const omitted = rows.length - lines.length - const shown = `${lines.length} ${lines.length === 1 ? 'row' : 'rows'}` - const size = omitted > 0 ? `${shown} of ${rows.length}, ${omitted} omitted for length` : shown - const content = `${describe(size)}${lines.join('\n')}` + const content = `${describe(sizeClause(lines.length, rows.length - lines.length))}${lines.join('\n')}` return { type: 'table_selection', tag: label ? `@${label}` : '@', From d939aea74a9fc8da969cabd70304ed97a55155be Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:15:00 -0700 Subject: [PATCH 15/26] fix(chat): align MothershipChat's onContextRemove with the surface contract The remaining-contexts argument was added to ChatSurfaceContextValue but not to MothershipChat's own prop type, which forwards straight into it. Nothing passes the handler there today so it typechecked (fewer params is assignable), but the two declarations of the same wiring disagreed. Does not change behavior: home still wires the remove handler only to the empty-state surface, as it did before this PR. --- .../home/components/mothership-chat/mothership-chat.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 8be99a12122..fb33d9ec1de 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -64,7 +64,12 @@ interface MothershipChatProps { userId?: string chatId?: string onContextAdd?: (context: ChatContext) => void - onContextRemove?: (context: ChatContext) => void + /** + * Receives the input's context list AFTER the removal, so the owner can tell + * whether another chip still references the removed chip's resource. Matches + * `ChatSurfaceContextValue`, which this forwards to. + */ + onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void draftScopeKey?: string layout?: 'mothership-view' | 'copilot-view' From d3b6878697cec1338e404349c5a272078875914c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:23:32 -0700 Subject: [PATCH 16/26] refactor(chat): apply cleanup pass findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emcn design: the table context menu built its Add-to-Chat label in the parent and never pluralized it, so right-clicking a single row read 'Add rows to Chat' directly above 'Delete row'. Derive it inside ContextMenu from selectedRowCount like every sibling label, with an addToChatCellScoped boolean mirroring workflowCellScoped. Also more correct: selectedRowCount accounts for a select-all beyond the loaded page, which contextMenuRowIds.length does not. callbacks: drop two useCallback wrappers whose identity nothing observes — both handleAddSelectionToChat handlers feed unmemoized components (one through an inline arrow). buildSelectionContext stays wrapped; it is a real dep of the copy-bridge effect. comments: fold a duplicated rationale paragraph in handleAddSelectionToChat left by two commits stacking, drop a {@link MothershipHandoff} that resolves to nothing (the type is not imported there), and trim a rowIds doc that restated its own type. Skipped, deliberately: the effects pass proposed moving buildContext out of useSelectionCopyBridge's deps behind a latest-ref. buildSelectionContext is already stable, so churn is near-zero, and it would leave two sibling useCallbacks purposeless. --- .../rich-markdown-editor/rich-markdown-editor.tsx | 4 ++-- .../files/components/file-viewer/text-editor.tsx | 4 ++-- .../components/context-menu/context-menu.tsx | 15 ++++++++++++--- .../components/table-grid/table-grid.tsx | 6 ++---- apps/sim/lib/mothership/events.ts | 4 ++-- apps/sim/stores/panel/types.ts | 2 +- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 2d0e2c6d777..ffb118973d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1154,10 +1154,10 @@ export function LoadedRichMarkdownEditor({ } }, [editor, file.id, file.name]) - const handleAddSelectionToChat = useCallback(() => { + const handleAddSelectionToChat = () => { const context = buildSelectionContext() if (context) addToChat(context) - }, [addToChat, buildSelectionContext]) + } useSelectionCopyBridge(containerRef, buildSelectionContext) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 580a4538455..1c69ac81dc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -402,10 +402,10 @@ export const TextEditor = memo(function TextEditor({ } }, [file.id, file.name]) - const handleAddSelectionToChat = useCallback(() => { + const handleAddSelectionToChat = () => { const context = buildSelectionContext() if (context) addToChat(context) - }, [addToChat, buildSelectionContext]) + } const { content, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 2a8272f5553..88234af0e15 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -58,8 +58,12 @@ interface ContextMenuProps { disableDelete?: boolean /** Adds the selected rows / cell range to Chat as a reference. Omit to hide. */ onAddToChat?: () => void - /** Label describing the current selection scope, e.g. "3 rows" or "cell range". */ - addToChatLabel?: string + /** + * True when the selection is a spreadsheet-style cell range rather than whole + * rows, switching the label from row-scoped to cell-scoped. Mirrors + * {@link ContextMenuProps.workflowCellScoped}. + */ + addToChatCellScoped?: boolean } export function ContextMenu({ @@ -85,7 +89,7 @@ export function ContextMenu({ disableDuplicate = false, disableDelete = false, onAddToChat, - addToChatLabel = 'Add to Chat', + addToChatCellScoped = false, }: ContextMenuProps) { const count = selectedRowCount.toLocaleString() const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' @@ -107,6 +111,11 @@ export function ContextMenu({ runningInSelectionCount === 1 ? 'Stop running workflow' : `Stop ${runningInSelectionCount} running workflows` + const addToChatLabel = addToChatCellScoped + ? 'Add cell range to Chat' + : selectedRowCount > 1 + ? `Add ${count} rows to Chat` + : 'Add row to Chat' return ( 0 ? handleAddSelectionToChat : undefined} - addToChatLabel={contextMenuColumnIds ? 'Add cell range to Chat' : 'Add rows to Chat'} + addToChatCellScoped={Boolean(contextMenuColumnIds)} /> Date: Sat, 1 Aug 2026 11:26:54 -0700 Subject: [PATCH 17/26] fix(chat): don't attach a selection chip to a copy from a nested input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot: a copy from a field inside the editor — Monaco's find box being the common one — bubbles to the container while the document still holds a highlight, so the bridge attached the editor selection to text the user never copied. Chat paste then prefers the custom MIME and inserts a reference chip instead of the search term. Skips INPUT targets only. Copying the table grid's INPUT/TEXTAREA guard would have suppressed the chip on the main copy path this hook exists for: Monaco's own editing surface is a hidden textarea, unlike the grid's cell editors, which really are form fields. Tests cover both directions — chip attached from the textarea surface, skipped from a nested input — and were verified to fail against the missing guard and against the INPUT+TEXTAREA variant. --- .../use-selection-copy-bridge.test.tsx | 88 +++++++++++++++++++ .../file-viewer/use-selection-copy-bridge.ts | 9 ++ 2 files changed, 97 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx new file mode 100644 index 00000000000..b457be62f2a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef, type RefObject } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard' +import type { ChatContext } from '@/stores/panel' +import { useSelectionCopyBridge } from './use-selection-copy-bridge' + +const selection: ChatContext = { + kind: 'file_selection', + fileId: 'f1', + fileName: 'notes.md', + label: 'notes.md:2-4', + text: 'the exact passage', +} + +let container: HTMLDivElement +let root: Root +let containerRef: RefObject +let buildContext: ReturnType + +/** + * Mirrors the editors this hook wraps: Monaco's editing surface is a hidden + * textarea, and its find widget is a real input nested in the same container. + */ +function Host() { + useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null) + return ( +
+