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 5cd2f43dd3a..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 @@ -12,14 +12,21 @@ import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' +import { + buildFileSelectionLabel, + 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 { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' 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 { announceAgentApplying, clearAgentApplying, @@ -1124,6 +1131,36 @@ 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 + return { + kind: 'file_selection', + fileId: file.id, + fileName: file.name, + label: buildFileSelectionLabel(file.name), + text: truncateSelectionText(text), + } + }, [editor, file.id, file.name]) + + const handleAddSelectionToChat = useCallback(() => { + const context = buildSelectionContext() + if (context) addToChat(context) + }, [addToChat, buildSelectionContext]) + + useSelectionCopyBridge(containerRef, buildSelectionContext) + // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder @@ -1135,7 +1172,13 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {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, + fileName: file.name, + label: buildFileSelectionLabel(file.name, startLine, endLine), + 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..a203880eca5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts @@ -0,0 +1,35 @@ +'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. + * + * 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. + * + * @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, + 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 50cec53dfa9..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 @@ -75,6 +75,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + table_selection: { + label: 'Table selection', + renderIcon: ({ className }) => , + }, file: { label: 'File', renderIcon: ({ context, className }) => { @@ -82,6 +86,15 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record }, }, + file_selection: { + label: 'File selection', + renderIcon: ({ context, className }) => { + // 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 + }, + }, 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..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 @@ -160,6 +160,31 @@ export function serializeSelectionForClipboard( return result } +/** + * Finds the selection-scoped chips (`file_selection` / `table_selection`) whose + * 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, + 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..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 @@ -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 { + prepareContextForInsert, restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -461,6 +467,49 @@ export function usePromptEditor({ [textareaRef, addContextNotified] ) + /** + * 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 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.map((c) => `@${c.label} `).join('')}` + const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}` + + pendingCursorRef.current = insertAt + insertText.length + valueRef.current = newValue + setValueState(newValue) + } + + for (const context of prepared) addContextNotified(context) + }, + [textareaRef, addContextNotified] + ) + /** * Only reachable via Radix's own dismiss detection (outside click / * Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass @@ -876,6 +925,31 @@ 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() + 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 ? ' ' : ''}@${prepared.label} ` + textarea.setRangeText(insert, selStart, selEnd, 'end') + const caret = selStart + insert.length + contextManagementRef.current.addContext(prepared) + valueRef.current = textarea.value + setValueState(textarea.value) + 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 +1041,11 @@ 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 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 => { @@ -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 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 55d73124605..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 @@ -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?.contexts?.length) return + e.preventDefault() + editorRef.current.insertContextChips(detail.contexts) + 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..6113f9d4f47 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -27,6 +27,7 @@ import { MothershipHandoffStorage, } from '@/lib/core/utils/browser-storage' import { + addMothershipContexts, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' @@ -329,19 +330,35 @@ 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) + if (!handoff) return + if (handoff.message) { + sendMessage(handoff.message, undefined, handoff.contexts) + return + } + 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]) function resolveResourceFromContext( @@ -355,24 +372,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. 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 context.fileName + if (context.kind === 'table_selection') return context.tableName + 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..17fee29df74 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,21 @@ 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' && + 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.fileName === 'string' && + typeof value.text === 'string' + ) case 'folder': return typeof value.folderId === 'string' case 'filefolder': @@ -3221,6 +3234,21 @@ 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' + ? { + fileName: c.fileName, + text: c.text, + ...(c.startLine ? { startLine: c.startLine } : {}), + ...(c.endLine ? { endLine: c.endLine } : {}), + } + : {}), + ...(c.kind === 'table_selection' + ? { + tableName: c.tableName, + 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..4f97cc541b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -148,6 +148,19 @@ export interface ChatMessageContext { blockType?: string skillId?: string 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. */ + 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 da0f38bc8dc..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 @@ -11,6 +11,12 @@ 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, +} from '@/lib/copilot/chat/selection-context' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, @@ -41,8 +47,10 @@ 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 { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' import { useContextMenu, useTable } from '../../hooks' import type { EditingCell, QueryOptions, SaveReason } from '../../types' @@ -306,6 +314,85 @@ 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. 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, `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[] + complete: boolean + buildCells: (row: TableRowType) => string[] + context: ChatContext | null +}): boolean { + const { rows, context } = opts + if (!context || !opts.complete || 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') + ) + attachSelectionContextToClipboard(opts.clipboardData, 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 @@ -992,6 +1079,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 @@ -2925,6 +3015,31 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() + // Only an explicit multi-row selection can take this path: a filtered + // select-all ('all') pages in rows beyond those loaded, which the async + // 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({ + clipboardData: e.clipboardData, + rows: selectedRows, + complete: true, + buildCells: (row) => cols.map((col) => cellToText(row.data[col.key], col)), + context: buildTableSelectionContext({ + tableId, + tableName: tableNameRef.current, + totalColumnCount: cols.length, + // 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 + } writeSelectionToClipboard({ loadRows: rowSel.kind === 'all' @@ -2953,6 +3068,25 @@ export function TableGrid({ if (name) colNames.push(name) } const colByKey = new Map(cols.map((c) => [c.key, c])) + + // 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, + complete: currentRows.length >= selectAllTotalRef.current, + buildCells: (row) => + colNames.map((name) => cellToText(row.data[name], colByKey.get(name))), + context: buildTableSelectionContext({ + tableId, + 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), selectRow: () => true, @@ -2964,6 +3098,22 @@ export function TableGrid({ return } + // 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++) { const cells: string[] = [] @@ -3682,6 +3832,82 @@ 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 + // 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]) + + 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. + // 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) + // 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. + } + } + const context = buildTableSelectionContext({ + tableId, + tableName: tableData?.name, + totalColumnCount: displayColumns.length, + rowIds: sourceRowIds, + columnIds: contextMenuColumnIds, + }) + if (context) addToChat(context) + }, [ + addToChat, + contextMenuRowIds, + contextMenuColumnIds, + contextMenuIsSelectAll, + contextMenu.row, + tableId, + tableData?.name, + displayColumns.length, + ]) + const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null /** @@ -4404,6 +4630,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'} /> > = {}) { + 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('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'] }) + + expect(prepareContextForInsert(range, [tableSelection()])).not.toBeNull() + }) + + it('passes an uncontested context through unchanged', () => { + const context = tableSelection() + + 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/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..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 @@ -190,6 +190,24 @@ type IntegrationContext = Extract type SlashCommandContext = Extract type SkillContext = Extract type McpContext = Extract +type FileSelectionContext = Extract +type TableSelectionContext = Extract + +/** + * 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 + const inA = new Set(a) + return b.every((id) => inA.has(id)) +} /** * Checks if two contexts of the same kind are equal by their ID fields. @@ -231,6 +249,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 +332,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 new file mode 100644 index 00000000000..b94bb65b970 --- /dev/null +++ b/apps/sim/hooks/use-add-to-chat.ts @@ -0,0 +1,34 @@ +'use client' + +import { useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { addMothershipContexts } from '@/lib/mothership/events' +import type { ChatContext } from '@/stores/panel' + +/** + * Returns a callback that attaches a context chip to the Sim Agent (Chat) input + * 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 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) => { + if (addMothershipContexts([context])) return + if (!workspaceId) return + if (MothershipHandoffStorage.store({ contexts: [context] }, workspaceId)) { + router.push(`/workspace/${workspaceId}/home`) + } + }, + [workspaceId, router] + ) +} diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 96878919b87..4b802c1f27f 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,13 @@ 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(), + 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(), }) 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..64e01fe7058 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -4,15 +4,26 @@ 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 } = 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 +305,252 @@ 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([]) + }) + + 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', () => { + 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([]) + }) + + 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 1d5096fa84d..507beb8eddc 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,10 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +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 { @@ -23,7 +27,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 +48,9 @@ type AgentContextType = | 'logs' | 'knowledge' | 'table' + | 'table_selection' | 'file' + | 'file_selection' | 'workflow_block' | 'docs' | 'folder' @@ -190,6 +199,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 +881,122 @@ 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 + * 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 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}` : '@', + content, + path, + } +} + +/** 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, ' ') +} + +/** + * 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, + 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(' | ')} |` + + // 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}` : '@', + 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..15108c9dec2 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts @@ -0,0 +1,114 @@ +/** + * @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', + fileName: 'notes.md', + label: 'notes.md:2-4', + text: 'the exact passage', + startLine: 2, + endLine: 4, +} + +const tableSelection: ChatContext = { + kind: 'table_selection', + tableId: 'tbl_1', + tableName: 'Sales', + 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', + 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() + }) + + it('rejects a table selection with no rows', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ + kind: 'table_selection', + tableId: 'tbl_1', + tableName: 'Sales', + 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..4f031862e4f --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.ts @@ -0,0 +1,65 @@ +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' && + 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 + ) { + 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..4f88dfb03b4 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildFileSelectionLabel, + buildTableSelectionLabel, + MAX_FILE_SELECTION_TEXT_LENGTH, + truncateSelectionText, +} 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('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') + }) +}) + +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)') + }) +}) + +describe('truncateSelectionText', () => { + it('leaves text within the bound untouched', () => { + expect(truncateSelectionText('short passage')).toBe('short passage') + }) + + 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) + + 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 new file mode 100644 index 00000000000..b436d36ae3f --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.ts @@ -0,0 +1,84 @@ +/** + * 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 + +/** + * 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 + +/** + * 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` 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 + * resolves collisions through `uniqueContextLabel`. + */ +export function buildFileSelectionLabel( + fileName: string, + startLine?: number, + endLine?: number +): string { + if (!startLine) return `${fileName} (selection)` + const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}` + return `${fileName}:${range}` +} + +/** + * 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 +): string { + const rows = `${rowCount} ${rowCount === 1 ? 'row' : 'rows'}` + if (!columnCount) return `${tableName} (${rows})` + const cols = `${columnCount} ${columnCount === 1 ? 'col' : 'cols'}` + 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..0b64ee5798a 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,74 @@ 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 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) + // 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 f5bb0cc483d..af43319d717 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -300,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` @@ -319,40 +330,64 @@ export interface 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 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. + * + * 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 : [] + } + /** * Retrieve and consume the stored handoff for `workspaceId`. A handoff owned * by a different workspace is left untouched for its owner — its tagged run * 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 { - const data = BrowserStorage.getItem<{ - message?: string - contexts?: ChatContext[] - workspaceId?: string - timestamp?: number - } | null>(MothershipHandoffStorage.KEY, null) + static consume( + workspaceId: string, + maxAge: number = MothershipHandoffStorage.MAX_AGE_MS + ): MothershipHandoff | null { + const data = BrowserStorage.getItem(MothershipHandoffStorage.KEY, null) if (!data) { return null @@ -364,16 +399,17 @@ 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 { diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 9143a73c5b5..a4dc7d4c5b7 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,15 +41,48 @@ 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 +} + +/** + * 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' + +export interface MothershipAddContextDetail { + /** The contexts to attach as chips, in insertion order. */ + contexts: ChatContext[] +} + +/** + * 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 addMothershipContexts(contexts: ChatContext[]): boolean { + if (contexts.length === 0) return false + const consumed = dispatchClaimable(MOTHERSHIP_ADD_CONTEXT_EVENT, { + contexts, }) + logger.info('Dispatched mothership add-context event', { count: contexts.length, consumed }) return consumed } diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 2cf9e357b63..971c37b7957 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1375,6 +1375,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..2305f5ed39c 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -24,7 +24,45 @@ 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 + /** + * 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[] + /** + * 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 + /** 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, 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 + } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } | { kind: 'scheduledtask'; scheduleId: string; label: string }