diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx index 904d3c06a11..3f1fb5bd805 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '@sim/emcn' -import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' +import { Blimp, Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons' import { Scissors } from 'lucide-react' interface EditorContextMenuProps { @@ -23,6 +23,8 @@ interface EditorContextMenuProps { onPaste: () => void onSelectAll: () => void onFind: () => void + /** Adds the current selection to Chat as a reference. Omit to hide the item. */ + onAddToChat?: () => void } export function EditorContextMenu({ @@ -37,6 +39,7 @@ export function EditorContextMenu({ onPaste, onSelectAll, onFind, + onAddToChat, }: EditorContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -60,6 +63,15 @@ export function EditorContextMenu({ sideOffset={2} onCloseAutoFocus={(e) => e.preventDefault()} > + {onAddToChat && ( + <> + + + Add to chat + + + + )} {canEdit && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 56b4dd78389..8036172017c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { Blimp } from '@sim/emcn/icons' import { posToDOMRect } from '@tiptap/core' import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' @@ -54,6 +55,8 @@ interface EditorBubbleMenuProps { editor: Editor /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ scrollContainerRef: React.RefObject + /** Adds the current selection to Chat as a reference. Omit to hide the action. */ + onAddToChat?: () => void } /** @@ -62,7 +65,11 @@ interface EditorBubbleMenuProps { * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar * stays correct without re-rendering the editor on every transaction. */ -export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) { +export function EditorBubbleMenu({ + editor, + scrollContainerRef, + onAddToChat, +}: EditorBubbleMenuProps) { const [linkValue, setLinkValue] = useState(null) const linkInputRef = useRef(null) const linkRangeRef = useRef<{ from: number; to: number } | null>(null) @@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen ) : ( <> + {onAddToChat && ( + <> + + + + )} > label: string shortcut?: string isActive?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9107dbd795d..addc0a6145f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { Fragment, Slice } from '@tiptap/pm/model' @@ -9,13 +9,21 @@ import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' +import { + buildFileSelectionLabel, + selectionKey, + truncateSelectionText, +} from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' +import { useAddToChat } from '@/hooks/use-add-to-chat' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' +import type { ChatContext } from '@/stores/panel' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' +import { useSelectionCopyBridge } from '../use-selection-copy-bridge' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' import { @@ -557,12 +565,46 @@ export function LoadedRichMarkdownEditor({ [] ) + const addToChat = useAddToChat() + const buildSelectionContext = useCallback((): ChatContext | null => { + if (!editor) return null + const { from, to } = editor.state.selection + if (from === to) return null + const text = editor.state.doc.textBetween(from, to, '\n') + if (!text.trim()) return null + // Markdown has no native line numbers; approximate from newlines before the + // selection so repeated selections in the same file get distinct labels. + const startLine = editor.state.doc.textBetween(0, from, '\n').split('\n').length + const endLine = startLine + text.split('\n').length - 1 + return { + kind: 'file_selection', + fileId: file.id, + label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + text: truncateSelectionText(text), + startLine, + endLine, + } + }, [editor, file.id, file.name]) + + const handleAddSelectionToChat = useCallback(() => { + const context = buildSelectionContext() + if (context) addToChat(context) + }, [addToChat, buildSelectionContext]) + + useSelectionCopyBridge(containerRef, buildSelectionContext) + return (
- {editor && } + {editor && ( + + )} {editor && } {editor && } { + const editor = monacoEditorRef.current + const sel = editor?.getSelection() + const model = editor?.getModel() + if (!editor || !sel || sel.isEmpty() || !model) return null + const text = model.getValueInRange(sel) + if (!text.trim()) return null + const startLine = sel.startLineNumber + const endLine = sel.endLineNumber + return { + kind: 'file_selection', + fileId: file.id, + label: buildFileSelectionLabel(file.name, startLine, endLine, selectionKey([text])), + text: truncateSelectionText(text), + startLine, + endLine, + } + }, [file.id, file.name]) + + const handleAddSelectionToChat = useCallback(() => { + const context = buildSelectionContext() + if (context) addToChat(context) + }, [addToChat, buildSelectionContext]) const { content, @@ -394,6 +427,10 @@ export const TextEditor = memo(function TextEditor({ }) contentRef.current = content + // Enable once content has loaded — the container (and Monaco) only mount after + // the `isContentLoading` early return below, so the bridge must (re-)attach then. + useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading) + useEffect(() => { const editor = monacoEditorRef.current if (!editor) return @@ -650,6 +687,10 @@ export const TextEditor = memo(function TextEditor({ onClose={closeContextMenu} hasSelection={contextMenu.hasSelection} canEdit={!isEditorReadOnly} + onAddToChat={() => { + handleAddSelectionToChat() + closeContextMenu() + }} onCut={() => { monacoEditorRef.current?.focus() monacoEditorRef.current?.trigger( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts new file mode 100644 index 00000000000..933ec91c1b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts @@ -0,0 +1,36 @@ +'use client' + +import { type RefObject, useEffect } from 'react' +import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import type { ChatContext } from '@/stores/panel' + +/** + * Rides a selection {@link ChatContext} onto the editor's native copy so a + * highlighted passage copied with Cmd+C pastes into Chat as a reference chip. + * + * The listener is attached to `containerRef` in the BUBBLE phase so it runs + * AFTER the inner editor's own copy handler (Monaco and ProseMirror both call + * `clearData()` then write `text/plain`/`text/html`) — the custom + * `text/x-sim-selection` type is added last and survives, leaving normal copy + * untouched. `buildContext` returns `null` when there is no non-empty selection. + * + * `enabled` lets a caller whose container mounts late (e.g. behind a loading + * gate) re-run the effect once the node exists — a ref object isn't reactive, so + * without it the effect would bail on the first render and never re-attach. + */ +export function useSelectionCopyBridge( + containerRef: RefObject, + buildContext: () => ChatContext | null, + enabled = true +): void { + useEffect(() => { + const dom = containerRef.current + if (!dom || !enabled) return + const onCopy = (e: ClipboardEvent) => { + const context = buildContext() + if (context) attachSelectionContextToClipboard(e.clipboardData, context) + } + dom.addEventListener('copy', onCopy) + return () => dom.removeEventListener('copy', onCopy) + }, [containerRef, buildContext, enabled]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 233b0e8d202..968257a51a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -12,6 +12,7 @@ import { } from '@sim/emcn/icons' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' +import { fileNameFromSelectionLabel } from '@/lib/copilot/chat/selection-context' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle } from '@/blocks/icon-color' import { getBlockRegistry } from '@/blocks/registry' @@ -75,6 +76,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + table_selection: { + label: 'Table selection', + renderIcon: ({ className }) => , + }, file: { label: 'File', renderIcon: ({ context, className }) => { @@ -82,6 +87,15 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record }, }, + file_selection: { + label: 'File selection', + renderIcon: ({ context, className }) => { + // Strip the `:line` suffix so `getDocumentIcon` reads the real extension + // (e.g. `md`, not `md:12-40`) and shows the correct file glyph. + const FileDocIcon = getDocumentIcon('', fileNameFromSelectionLabel(context.label)) + return + }, + }, folder: { label: 'Folder', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx index b3f9b824970..c2d0a2146b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx @@ -26,8 +26,13 @@ interface ChatSurfaceContextValue { userId?: string /** Notifies the surface owner that a context chip was added to the input. */ onContextAdd: (context: ChatContext) => void - /** Notifies the surface owner that a context chip was removed from the input. */ - onContextRemove: (context: ChatContext) => void + /** + * Notifies the surface owner that a context chip was removed from the input. + * `remaining` is the input's context list AFTER the removal, so the owner can + * tell whether any other chip still references the removed chip's resource + * before closing a shared slideover tab. + */ + onContextRemove: (context: ChatContext, remaining: ChatContext[]) => void /** Opens a workspace resource referenced from rendered message content. */ onWorkspaceResourceSelect: (resource: MothershipResource) => void } @@ -42,7 +47,7 @@ interface ChatSurfaceProviderProps { chatId?: string userId?: string onContextAdd?: (context: ChatContext) => void - onContextRemove?: (context: ChatContext) => void + onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void children: ReactNode } @@ -74,8 +79,8 @@ export function ChatSurfaceProvider({ const stableOnContextAdd = useCallback((context: ChatContext) => { onContextAddRef.current?.(context) }, []) - const stableOnContextRemove = useCallback((context: ChatContext) => { - onContextRemoveRef.current?.(context) + const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => { + onContextRemoveRef.current?.(context, remaining) }, []) const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => { onWorkspaceResourceSelectRef.current?.(resource) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 39abdc7b359..90a78369cab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -160,6 +160,31 @@ export function serializeSelectionForClipboard( return result } +/** + * Finds the selection-scoped chips (`file_selection` / `table_selection`) whose + * highlighted token falls inside `selectedText`. These kinds carry an inline + * text blob / row-id array that can't fit a portable `sim:kind/id` link, so the + * chat input's copy/cut path round-trips them through the custom + * `text/x-sim-selection` clipboard MIME instead. Uses the overlay's exact + * tokenization so a label that is a substring of another never false-matches. + */ +export function selectionContextsInText( + selectedText: string, + contexts: ChatContext[] +): ChatContext[] { + const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts)) + if (ranges.length === 0) return [] + const found: ChatContext[] = [] + for (const range of ranges) { + const label = stripMentionTrigger(range.token) + const matched = contexts.find((c) => c.label === label) + if (matched && (matched.kind === 'file_selection' || matched.kind === 'table_selection')) { + found.push(matched) + } + } + return found +} + /** * Parses all portable chip markdown links from a string, in source order. * diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index a969e39b741..e9893f96902 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -1,9 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + attachSelectionContextToClipboard, + readSelectionContextFromClipboard, +} from '@/lib/copilot/chat/selection-clipboard' import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import { chipDisplayToken, chipLinkToContext, parseChipLinks, + selectionContextsInText, serializeSelectionForClipboard, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' import { @@ -20,6 +25,7 @@ import { useMentionTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import { + isContextAlreadySelected, restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -461,6 +467,40 @@ export function usePromptEditor({ [textareaRef, addContextNotified] ) + /** + * Inserts a context as an `@label` chip at the caret and registers it. Unlike + * the menu-driven inserts, this is triggered programmatically (the + * highlight-to-chat action in the file/table viewers) rather than by a typed + * `@`/`/` trigger, so it always inserts at the current cursor position. + */ + const insertContextChip = useCallback( + (context: ChatContext) => { + // A chip's `@label` token must be unique — `addContext` dedupes a matching + // label, so inserting a token for an already-present context would orphan + // it (a second token with no backing context). Skip and just focus. + if (isContextAlreadySelected(context, contextManagementRef.current.selectedContexts)) { + textareaRef.current?.focus() + return + } + const textarea = textareaRef.current + if (textarea) { + const currentValue = valueRef.current + const insertAt = textarea.selectionStart ?? currentValue.length + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) + const insertText = `${needsSpaceBefore ? ' ' : ''}@${context.label} ` + const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}` + const newPos = insertAt + insertText.length + + pendingCursorRef.current = newPos + valueRef.current = newValue + setValueState(newValue) + } + + addContextNotified(context) + }, + [textareaRef, addContextNotified] + ) + /** * Only reachable via Radix's own dismiss detection (outside click / * Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass @@ -876,6 +916,36 @@ export function usePromptEditor({ const handlePaste = useCallback((e: React.ClipboardEvent) => { const textarea = e.currentTarget + // A selection copied from a file/table (Cmd+C) carries its context on a + // custom clipboard type — paste it as a reference chip instead of plain text. + // Registers via `addContext` (not the notified path) so paste never opens a + // side panel, matching the portable-chip-link paste below. + const selectionContext = readSelectionContextFromClipboard(e.clipboardData) + if (selectionContext) { + e.preventDefault() + // A chip's `@label` token must be unique — `addContext` dedupes a matching + // label, so inserting a token for an already-present selection would orphan + // it (a second token with no backing context). Skip and keep focus, mirroring + // insertContextChip. + if ( + isContextAlreadySelected(selectionContext, contextManagementRef.current.selectedContexts) + ) { + return + } + const selStart = textarea.selectionStart ?? valueRef.current.length + const selEnd = textarea.selectionEnd ?? selStart + const needsSpaceBefore = selStart > 0 && !/\s/.test(valueRef.current.charAt(selStart - 1)) + const insert = `${needsSpaceBefore ? ' ' : ''}@${selectionContext.label} ` + textarea.setRangeText(insert, selStart, selEnd, 'end') + const newValue = textarea.value + const caret = selStart + insert.length + contextManagementRef.current.addContext(selectionContext) + valueRef.current = newValue + setValueState(newValue) + requestAnimationFrame(() => textarea.setSelectionRange(caret, caret)) + return + } + // Portable chip links (`[label](sim:kind/id)`) re-create their chip on // paste-back. Rewrite each link span to its `@label ` token (the trailing // space is REQUIRED so useContextManagement's sync effect doesn't purge the @@ -967,6 +1037,15 @@ export function usePromptEditor({ * text and round-trip by name. Returns true when it took over the clipboard * (the caller must then perform the cut deletion itself, since the default * was prevented). + * + * Selection chips (`file_selection` / `table_selection`) can't fit a portable + * link — their inline text / row-id payload lives only in the context. When + * the selection is exactly one such chip (the common copy/cut of a + * highlight-to-chat chip), ride its full context on the custom + * `text/x-sim-selection` MIME so paste restores it; otherwise it would leave a + * bare `@label` with no backing data. Mixed selections keep the portable/plain + * path (the single-slot MIME can't carry more than one), so the chip degrades + * to its label text there rather than dropping the rest of the selection. */ const writeSanitizedClipboard = useCallback( (e: React.ClipboardEvent): boolean => { @@ -975,10 +1054,18 @@ export function usePromptEditor({ const end = textarea.selectionEnd ?? 0 const selected = textarea.value.slice(start, end) if (!selected) return false - const serialized = serializeSelectionForClipboard( - selected, - contextManagementRef.current.selectedContexts - ) + const contexts = contextManagementRef.current.selectedContexts + const selectionChips = selectionContextsInText(selected, contexts) + const soleSelectionChip = + selectionChips.length === 1 && + selected.replace(chipDisplayToken(selectionChips[0]), '').trim().length === 0 + if (soleSelectionChip) { + e.preventDefault() + e.clipboardData.setData('text/plain', selected) + attachSelectionContextToClipboard(e.clipboardData, selectionChips[0]) + return true + } + const serialized = serializeSelectionForClipboard(selected, contexts) if (serialized === selected) return false e.preventDefault() e.clipboardData.setData('text/plain', serialized) @@ -1023,6 +1110,8 @@ export function usePromptEditor({ clear, focusAtEnd, insertResources, + /** Inserts a context as an `@label` chip at the caret (highlight-to-chat). */ + insertContextChip, insertSlashTrigger, openResourceMenu, /** The editor's textarea element — focus management, caret restore. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 55d73124605..8462c96409c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -15,6 +15,10 @@ import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { + MOTHERSHIP_ADD_CONTEXT_EVENT, + type MothershipAddContextDetail, +} from '@/lib/mothership/events' import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { @@ -172,6 +176,24 @@ const UserInputImpl = forwardRef(function UserI } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore + /** + * Attaches a context chip pushed from elsewhere in the app (the + * highlight-to-chat action in the file/table viewers). `preventDefault` claims + * the event so the producer knows a live input consumed it and skips its + * persist-and-navigate fallback. + */ + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + if (!detail?.context) return + e.preventDefault() + editorRef.current.insertContextChip(detail.context) + textareaRef.current?.focus() + } + window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler) + return () => window.removeEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler) + }, [textareaRef]) + const isFirstSaveRef = useRef(true) useEffect(() => { if (isFirstSaveRef.current) { @@ -223,7 +245,7 @@ const UserInputImpl = forwardRef(function UserI } } const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p))) - if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx)) + if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx, curr)) prevSelectedContextsRef.current = curr }, [editor.contexts]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 868df0fb460..339a0bd3d23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,14 +19,20 @@ import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' +import { + fileNameFromSelectionLabel, + tableNameFromSelectionLabel, +} from '@/lib/copilot/chat/selection-context' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, MothershipHandoffStorage, + MothershipPendingContextStorage, } from '@/lib/core/utils/browser-storage' import { + addMothershipContext, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' @@ -344,6 +350,29 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) if (handoff) sendMessage(handoff.message, undefined, handoff.contexts) }, [chatId, workspaceId, sendMessage]) + /** + * Drains contexts persisted by the highlight-to-chat action (standalone + * Files/Tables page). Runs after this component's mount effects — including + * `useChat`'s chat-init `setResources([])` — so re-dispatching each context + * inserts its chip in the (already mounted) conversation input AND opens its + * resource in the slideover without the reset wiping it. A ref guards against + * the StrictMode double-invoke draining twice. + */ + const hasDrainedPendingContextRef = useRef(false) + useEffect(() => { + if (hasDrainedPendingContextRef.current || !workspaceId) return + hasDrainedPendingContextRef.current = true + const pending = MothershipPendingContextStorage.consume(workspaceId) + for (const context of pending) { + // Open the resource in the slideover directly (deterministic — not + // dependent on the input's event listener being mounted yet), then + // dispatch the event so the mounted input inserts the chip. + handleContextAdd(context) + addMothershipContext(context) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only drain; handleContextAdd is stable enough for a one-shot + }, [workspaceId]) + function resolveResourceFromContext( context: ChatContext ): { type: MothershipResourceType; id: string } | null { @@ -355,24 +384,48 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null case 'table': return context.tableId ? { type: 'table', id: context.tableId } : null + case 'table_selection': + return context.tableId ? { type: 'table', id: context.tableId } : null case 'file': return context.fileId ? { type: 'file', id: context.fileId } : null + case 'file_selection': + return context.fileId ? { type: 'file', id: context.fileId } : null default: return null } } + /** + * Tab title for the resource a chip opens. Selection chips carry a + * location suffix in their label (`notes.md:12-40`, `Sales (3 rows)`); the + * underlying resource is the whole file/table, so strip the suffix. + */ + function resourceTitleForContext(context: ChatContext): string { + if (context.kind === 'file_selection') return fileNameFromSelectionLabel(context.label) + if (context.kind === 'table_selection') return tableNameFromSelectionLabel(context.label) + return context.label + } + function handleContextAdd(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (resolved) { - addResource({ ...resolved, title: context.label }) + addResource({ ...resolved, title: resourceTitleForContext(context) }) handleResourceEvent() } } - function handleInitialContextRemove(context: ChatContext) { + function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) { const resolved = resolveResourceFromContext(context) if (!resolved) return + // A whole-file chip and one or more of its selection chips (or several + // selections of the same file/table) all resolve to the same resource tab. + // Only close the tab once no remaining chip still references it, so removing + // one of several chips doesn't yank a slideover the others still point at. + const stillReferenced = remaining.some((other) => { + const otherResolved = resolveResourceFromContext(other) + return otherResolved?.type === resolved.type && otherResolved.id === resolved.id + }) + if (stillReferenced) return removeResource(resolved.type, resolved.id) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 66a5a47658a..14a5bf9cd16 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -315,8 +315,16 @@ function isChatContext(value: unknown): value is ChatContext { return value.knowledgeId === undefined || typeof value.knowledgeId === 'string' case 'table': return typeof value.tableId === 'string' + case 'table_selection': + return ( + typeof value.tableId === 'string' && + Array.isArray(value.rowIds) && + value.rowIds.every((id) => typeof id === 'string') + ) case 'file': return typeof value.fileId === 'string' + case 'file_selection': + return typeof value.fileId === 'string' && typeof value.text === 'string' case 'folder': return typeof value.folderId === 'string' case 'filefolder': @@ -3221,6 +3229,19 @@ export function useChat( ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), + ...(c.kind === 'file_selection' + ? { + text: c.text, + ...(c.startLine ? { startLine: c.startLine } : {}), + ...(c.endLine ? { endLine: c.endLine } : {}), + } + : {}), + ...(c.kind === 'table_selection' + ? { + rowIds: c.rowIds, + ...(c.columnIds ? { columnIds: c.columnIds } : {}), + } + : {}), })) const cachedUserMsg: PersistedMessage = { id: userMessageId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 4759bb9d1d6..b06b0cd437c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -148,6 +148,15 @@ export interface ChatMessageContext { blockType?: string skillId?: string serverId?: string + /** Selected passage for a `file_selection` context. */ + text?: string + /** 1-based inclusive line range for a `file_selection` context. */ + startLine?: number + endLine?: number + /** Selected row ids for a `table_selection` context. */ + rowIds?: string[] + /** Selected column ids for a `table_selection` cell range. */ + columnIds?: string[] } export interface ChatMessage { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 34d6561b689..8dc84f53592 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -8,6 +8,7 @@ import { import { ArrowDown, ArrowUp, + Blimp, Duplicate, Eye, Pencil, @@ -55,6 +56,10 @@ interface ContextMenuProps { */ disableDuplicate?: boolean disableDelete?: boolean + /** Adds the selected rows / cell range to Chat as a reference. Omit to hide. */ + onAddToChat?: () => void + /** Label describing the current selection scope, e.g. "3 rows" or "cell range". */ + addToChatLabel?: string } export function ContextMenu({ @@ -79,6 +84,8 @@ export function ContextMenu({ disableInsert = false, disableDuplicate = false, disableDelete = false, + onAddToChat, + addToChatLabel = 'Add to chat', }: ContextMenuProps) { const count = selectedRowCount.toLocaleString() const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' @@ -127,6 +134,15 @@ export function ContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {onAddToChat && ( + <> + + + {addToChatLabel} + + + + )} {contextMenu.columnName && canEditCell && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index ed93cdaca87..b31b7162310 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -10,6 +10,13 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' +import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import { + buildTableSelectionLabel, + MAX_TABLE_SELECTION_COLUMNS, + MAX_TABLE_SELECTION_ROWS, + selectionKey, +} from '@/lib/copilot/chat/selection-context' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, @@ -38,6 +45,7 @@ import { useUpdateTableRow, useUpdateWorkflowGroup, } from '@/hooks/queries/tables' +import { useAddToChat } from '@/hooks/use-add-to-chat' import { useInlineRename } from '@/hooks/use-inline-rename' import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' import type { DeletedRowSnapshot } from '@/stores/table/types' @@ -961,6 +969,9 @@ export function TableGrid({ const rowSelectionRef = useRef(rowSelection) rowSelectionRef.current = rowSelection + const tableNameRef = useRef(tableData?.name) + tableNameRef.current = tableData?.name + columnsRef.current = displayColumns schemaColumnsRef.current = columns workflowGroupsRef.current = tableWorkflowGroups @@ -2854,6 +2865,45 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() + // The async Clipboard API (`navigator.clipboard.write`) used by the paged + // path below replaces the whole clipboard and can't carry a custom MIME + // type. For an explicit multi-row ('some') selection whose rows are all + // loaded, do a SYNCHRONOUS event write instead so the chat-selection chip + // rides alongside `text/plain` (same as the cell-range path). 'all' + // (filtered select-all) and oversized selections fall through to the + // paged plain-text write, which can't carry the chip. + if (rowSel.kind === 'some') { + const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id)) + const allLoaded = selectedRows.length === rowSel.ids.size + if ( + allLoaded && + selectedRows.length > 0 && + selectedRows.length <= MAX_TABLE_SELECTION_ROWS + ) { + const text = selectedRows + .map((row) => cols.map((col) => cellToText(row.data[col.key], col)).join('\t')) + .join('\n') + e.clipboardData?.setData('text/plain', text) + if (tableNameRef.current) { + const rowIds = selectedRows.map((row) => row.id) + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rowIds.length, + undefined, + selectionKey(rowIds) + ), + rowIds, + }) + } + toast.success( + `Copied ${selectedRows.length} ${selectedRows.length === 1 ? 'row' : 'rows'}` + ) + return + } + } writeSelectionToClipboard({ loadRows: rowSel.kind === 'all' @@ -2882,6 +2932,50 @@ export function TableGrid({ if (name) colNames.push(name) } const colByKey = new Map(cols.map((c) => [c.key, c])) + + // When every row is loaded and within the chat-selection cap, do a + // SYNCHRONOUS event write so the table_selection chip rides alongside + // text/plain — the async paged path below replaces the whole clipboard + // and can't carry a custom MIME. Add-to-chat materializes the same + // scoped selection, so Cmd+C must be able to rebuild that chip. + const allLoaded = currentRows.length >= selectAllTotalRef.current + if ( + tableNameRef.current && + allLoaded && + currentRows.length > 0 && + currentRows.length <= MAX_TABLE_SELECTION_ROWS + ) { + const text = currentRows + .map((row) => + colNames.map((name) => cellToText(row.data[name], colByKey.get(name))).join('\t') + ) + .join('\n') + e.clipboardData?.setData('text/plain', text) + const rowIds = currentRows.map((row) => row.id) + const columnIds: string[] = [] + for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { + columnIds.push(getColumnId(cols[c])) + } + const scopedColumnIds = + columnIds.length > 0 && columnIds.length < cols.length + ? columnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) + : undefined + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rowIds.length, + scopedColumnIds?.length, + selectionKey([...rowIds, ...(scopedColumnIds ?? [])]) + ), + rowIds, + ...(scopedColumnIds ? { columnIds: scopedColumnIds } : {}), + }) + toast.success(`Copied ${rowIds.length} ${rowIds.length === 1 ? 'row' : 'rows'}`) + return + } + writeSelectionToClipboard({ loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), selectRow: () => true, @@ -2893,6 +2987,42 @@ export function TableGrid({ return } + // Ride a table_selection (bounded rows + the range's columns) onto the + // clipboard so pasting this cell range into Chat yields the same chip. + if (tableNameRef.current) { + const rangeRowIds: string[] = [] + for ( + let r = sel.startRow; + r <= sel.endRow && rangeRowIds.length < MAX_TABLE_SELECTION_ROWS; + r++ + ) { + const row = currentRows[r] + if (row) rangeRowIds.push(row.id) + } + const rangeColumnIds: string[] = [] + for (let c = sel.startCol; c <= sel.endCol && c < cols.length; c++) { + rangeColumnIds.push(getColumnId(cols[c])) + } + const columnIds = + rangeColumnIds.length > 0 && rangeColumnIds.length < cols.length + ? rangeColumnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) + : undefined + if (rangeRowIds.length > 0) { + attachSelectionContextToClipboard(e.clipboardData, { + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableNameRef.current, + rangeRowIds.length, + columnIds?.length, + selectionKey([...rangeRowIds, ...(columnIds ?? [])]) + ), + rowIds: rangeRowIds, + ...(columnIds ? { columnIds } : {}), + }) + } + } + const lines: string[] = [] for (let r = sel.startRow; r <= sel.endRow; r++) { const cells: string[] = [] @@ -3610,6 +3740,80 @@ export function TableGrid({ ) : contextMenuRowIds.length || 1 + /** + * Column ids for an "Add to chat" table selection. A spreadsheet-style cell + * range AND a column-header selection (which spans every row of the chosen + * columns) narrow the columns; whole-row (gutter) selections and single rows + * send every column (undefined). A range spanning all columns is equivalent to + * whole rows, so it also collapses to undefined. + */ + const contextMenuColumnIds = useMemo(() => { + if (!contextMenu.isOpen || !contextMenu.row) return undefined + if ( + !rowSelectionIsEmpty(rowSelection) && + rowSelectionIncludes(rowSelection, contextMenu.row.id) + ) { + return undefined + } + const sel = normalizedSelection + if (!sel) return undefined + const contextRowArrayIndex = rows.findIndex((r) => r.id === contextMenu.row!.id) + if (contextRowArrayIndex < sel.startRow || contextRowArrayIndex > sel.endRow) return undefined + const ids: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + const col = displayColumns[c] + if (col) ids.push(getColumnId(col)) + } + return ids.length > 0 && ids.length < displayColumns.length ? ids : undefined + }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows, displayColumns]) + + const addToChat = useAddToChat() + const handleAddSelectionToChat = useCallback(async () => { + // A gutter select-all (filtered) or a column-header selection (every row of + // the chosen columns) covers rows beyond the loaded page that + // `contextMenuRowIds` reflects; drain up to the cap so the chip references as + // many rows as it can carry (bounded by MAX_TABLE_SELECTION_ROWS) instead of a + // silent loaded-only subset — mirroring how the copy path loads before writing. + let sourceRowIds = contextMenuRowIds + if (contextMenuIsSelectAll || isColumnSelectionRef.current) { + try { + const { rows: loaded } = await ensureRowsLoadedUpToRef.current(MAX_TABLE_SELECTION_ROWS) + // A column selection spans all rows; a gutter select-all filters by the + // (exclusion-aware) row selection. + const drained = ( + contextMenuIsSelectAll + ? loaded.filter((row) => rowSelectionIncludes(rowSelectionRef.current, row.id)) + : loaded + ).map((row) => row.id) + if (drained.length > 0) sourceRowIds = drained + } catch { + // Fall back to the already-loaded rows if the drain fails. + } + } + if (sourceRowIds.length === 0) return + const rowIds = sourceRowIds.slice(0, MAX_TABLE_SELECTION_ROWS) + const columnIds = contextMenuColumnIds?.slice(0, MAX_TABLE_SELECTION_COLUMNS) + addToChat({ + kind: 'table_selection', + tableId, + label: buildTableSelectionLabel( + tableData?.name ?? 'Table', + rowIds.length, + columnIds?.length, + selectionKey([...rowIds, ...(columnIds ?? [])]) + ), + rowIds, + ...(columnIds && columnIds.length > 0 ? { columnIds } : {}), + }) + }, [ + addToChat, + contextMenuRowIds, + contextMenuColumnIds, + contextMenuIsSelectAll, + tableId, + tableData?.name, + ]) + const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null /** @@ -4323,6 +4527,8 @@ export function TableGrid({ disableInsert={!canManualAddRow} disableDuplicate={!canInsertFullRow} disableDelete={!canDeleteRow} + onAddToChat={contextMenuRowIds.length > 0 ? handleAddSelectionToChat : undefined} + addToChatLabel={contextMenuColumnIds ? 'Add cell range to chat' : 'Add rows to chat'} /> void { + const { workspaceId } = useParams<{ workspaceId: string }>() + + return useCallback( + (context: ChatContext) => { + const consumed = addMothershipContext(context) + if (consumed) return + if (!workspaceId) return + MothershipPendingContextStorage.store(context, workspaceId) + // Hard navigation (not router.push): a full Chat mount reliably opens the + // resource in the slideover. A client-side transition races with useChat's + // resource-reset effect and drops the just-opened resource. + window.location.assign(`/workspace/${workspaceId}/home`) + }, + [workspaceId] + ) +} diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 96878919b87..c6c2bc99a4b 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -22,6 +22,11 @@ import { processContextsServer, resolveActiveResourceContext, } from '@/lib/copilot/chat/process-contents' +import { + MAX_FILE_SELECTION_TEXT_LENGTH, + MAX_TABLE_SELECTION_COLUMNS, + MAX_TABLE_SELECTION_ROWS, +} from '@/lib/copilot/chat/selection-context' import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' @@ -145,7 +150,9 @@ const ChatContextSchema = z.object({ 'knowledge', 'docs', 'table', + 'table_selection', 'file', + 'file_selection', 'folder', 'filefolder', 'scheduledtask', @@ -171,6 +178,11 @@ const ChatContextSchema = z.object({ scheduleId: z.string().optional(), tabId: z.string().optional(), terminalId: z.string().optional(), + text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(), + startLine: z.number().int().positive().optional(), + endLine: z.number().int().positive().optional(), + rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(), + columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(), }) const ChatMessageSchema = z.object({ diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index bb2172e71f3..aafd2c0e6d5 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -6,13 +6,20 @@ import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' -const { discoverServerTools, getSkillById } = vi.hoisted(() => ({ - discoverServerTools: vi.fn(), - getSkillById: vi.fn(), -})) +const { discoverServerTools, getSkillById, getWorkspaceFile, getTableById, getRowsByIds } = + vi.hoisted(() => ({ + discoverServerTools: vi.fn(), + getSkillById: vi.fn(), + getWorkspaceFile: vi.fn(), + getTableById: vi.fn(), + getRowsByIds: vi.fn(), + })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile })) +vi.mock('@/lib/table/service', () => ({ getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) /** * Overrides the global `@sim/db` mock: the logs-context tests below need @@ -294,3 +301,189 @@ 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([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 1d5096fa84d..2b7c5fcb274 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { truncateSelectionText } from '@/lib/copilot/chat/selection-context' import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { @@ -23,7 +24,10 @@ import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' +import { getColumnId } from '@/lib/table/column-keys' +import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' +import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getSkillById } from '@/lib/workflows/skills/operations' @@ -41,7 +45,9 @@ type AgentContextType = | 'logs' | 'knowledge' | 'table' + | 'table_selection' | 'file' + | 'file_selection' | 'workflow_block' | 'docs' | 'folder' @@ -190,6 +196,31 @@ export async function processContextsServer( path: result.path, } } + if (ctx.kind === 'file_selection' && ctx.fileId && currentWorkspaceId) { + return await resolveFileSelectionResource( + ctx.fileId, + currentWorkspaceId, + ctx.text ?? '', + ctx.label, + ctx.startLine, + ctx.endLine + ) + } + if ( + ctx.kind === 'table_selection' && + ctx.tableId && + Array.isArray(ctx.rowIds) && + ctx.rowIds.length > 0 && + currentWorkspaceId + ) { + return await resolveTableSelectionResource( + ctx.tableId, + currentWorkspaceId, + ctx.rowIds, + ctx.columnIds, + ctx.label + ) + } if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) { const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId) if (!result) return null @@ -847,6 +878,112 @@ 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, + } +} + +/** + * Resolves a table selection into an inline markdown table. Rows are re-fetched + * by id from the DB (never trusting client-sent cell values); when `columnIds` + * is present the projection is narrowed to that cell range, otherwise every + * column is included. + */ +async function resolveTableSelectionResource( + tableId: string, + workspaceId: string, + rowIds: string[], + columnIds: string[] | undefined, + label: string +): Promise { + const table = await getTableById(tableId) + if (!table || table.workspaceId !== workspaceId) return null + + const rows = await getRowsByIds(tableId, rowIds, workspaceId) + if (rows.length === 0) return null + + const allColumns: ColumnDefinition[] = table.schema?.columns ?? [] + // A cell range (`columnIds` present) narrows to those columns; whole-row + // selections use every column. If a cell range's columns no longer resolve + // (schema changed since the selection was made), keep the range scope empty + // and drop the resource — never silently expand a narrow selection into a + // full-table dump. + const hasColumnScope = Boolean(columnIds && columnIds.length > 0) + const columns = hasColumnScope + ? allColumns.filter((col) => columnIds?.includes(getColumnId(col))) + : allColumns + if (columns.length === 0) return null + + const header = `| ${columns.map((c) => c.name).join(' | ')} |` + const divider = `| ${columns.map(() => '---').join(' | ')} |` + const body = rows + .map((row) => { + const cells = columns.map((col) => { + const value = row.data[getColumnId(col)] + if (value === null || value === undefined) return '' + const cell = typeof value === 'string' ? value : JSON.stringify(value) + return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ') + }) + return `| ${cells.join(' | ')} |` + }) + .join('\n') + + const scope = columnIds && columnIds.length > 0 ? 'cell range' : 'rows' + const content = `Selected ${scope} from table "${table.name}" (${rows.length} ${ + rows.length === 1 ? 'row' : 'rows' + }):\n\n${header}\n${divider}\n${body}` + return { + type: 'table_selection', + tag: label ? `@${label}` : '@', + content, + path: canonicalTableVfsPath(table.name), + } +} + async function resolveFileFolderResource( folderId: string, workspaceId: string diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.test.ts b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts new file mode 100644 index 00000000000..4ba32165732 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ChatContext } from '@/stores/panel' +import { + attachSelectionContextToClipboard, + readSelectionContextFromClipboard, + SIM_SELECTION_MIME, +} from './selection-clipboard' + +/** Minimal DataTransfer stand-in (jsdom-free node env). */ +function fakeClipboard(initial: Record = {}) { + const store: Record = { ...initial } + return { + setData: (type: string, value: string) => { + store[type] = value + }, + getData: (type: string) => store[type] ?? '', + } as unknown as DataTransfer +} + +const fileSelection: ChatContext = { + kind: 'file_selection', + fileId: 'wf_1', + label: 'notes.md:2-4', + text: 'the exact passage', + startLine: 2, + endLine: 4, +} + +const tableSelection: ChatContext = { + kind: 'table_selection', + tableId: 'tbl_1', + label: 'Sales (2 rows)', + rowIds: ['r1', 'r2'], +} + +describe('selection clipboard codec', () => { + it('round-trips a file selection through the custom MIME type', () => { + const dt = fakeClipboard() + attachSelectionContextToClipboard(dt, fileSelection) + expect(dt.getData(SIM_SELECTION_MIME)).toContain('file_selection') + expect(readSelectionContextFromClipboard(dt)).toEqual(fileSelection) + }) + + it('round-trips a table selection', () => { + const dt = fakeClipboard() + attachSelectionContextToClipboard(dt, tableSelection) + expect(readSelectionContextFromClipboard(dt)).toEqual(tableSelection) + }) + + it('does not touch text/plain (rides alongside it)', () => { + const dt = fakeClipboard({ 'text/plain': 'the exact passage' }) + attachSelectionContextToClipboard(dt, fileSelection) + expect(dt.getData('text/plain')).toBe('the exact passage') + }) + + it('returns null when the custom type is absent (plain paste)', () => { + expect(readSelectionContextFromClipboard(fakeClipboard({ 'text/plain': 'hi' }))).toBeNull() + }) + + it('returns null on malformed JSON', () => { + expect( + readSelectionContextFromClipboard(fakeClipboard({ [SIM_SELECTION_MIME]: '{not json' })) + ).toBeNull() + }) + + it('rejects a file selection missing its text', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file_selection', fileId: 'wf_1', label: 'x' }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) + + it('rejects a table selection with no rows', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ + kind: 'table_selection', + tableId: 'tbl_1', + label: 'x', + rowIds: [], + }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) + + it('rejects an unrelated context kind', () => { + const dt = fakeClipboard({ + [SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file', fileId: 'wf_1', label: 'x' }), + }) + expect(readSelectionContextFromClipboard(dt)).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.ts b/apps/sim/lib/copilot/chat/selection-clipboard.ts new file mode 100644 index 00000000000..9ee48706ad8 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-clipboard.ts @@ -0,0 +1,59 @@ +import type { ChatContext } from '@/stores/panel' + +/** + * Custom clipboard MIME type carrying a selection {@link ChatContext} so a + * highlighted passage copied from a file/table can be pasted into the Chat input + * as a reference chip. Written alongside `text/plain` (never replacing it), so + * pasting anywhere else still yields the plain selection text. + */ +export const SIM_SELECTION_MIME = 'text/x-sim-selection' + +/** + * Attaches a selection context to a copy event's clipboard. Adds the custom MIME + * type WITHOUT calling `preventDefault`, so the editor's own copy handler (Monaco, + * ProseMirror) still writes `text/plain`/`text/html` — the custom type simply + * rides along on the shared `DataTransfer`. + */ +export function attachSelectionContextToClipboard( + clipboardData: DataTransfer | null, + context: ChatContext +): void { + if (!clipboardData) return + try { + clipboardData.setData(SIM_SELECTION_MIME, JSON.stringify(context)) + } catch { + // Some browsers reject custom types mid-gesture; degrade to plain-text copy. + } +} + +/** + * Reads a selection context previously written by + * {@link attachSelectionContextToClipboard}, or null when the clipboard carries + * no (or an invalid) selection payload. + */ +export function readSelectionContextFromClipboard( + clipboardData: DataTransfer | null +): ChatContext | null { + const raw = clipboardData?.getData(SIM_SELECTION_MIME) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as ChatContext + if (!parsed || typeof parsed.label !== 'string') return null + // Require each kind's resolving field so a chip never pastes only to + // resolve to nothing server-side. + if (parsed.kind === 'file_selection' && typeof parsed.text === 'string' && parsed.fileId) { + return parsed + } + if ( + parsed.kind === 'table_selection' && + parsed.tableId && + Array.isArray(parsed.rowIds) && + parsed.rowIds.length > 0 + ) { + return parsed + } + } catch { + // Malformed payload — fall back to plain-text paste. + } + return null +} diff --git a/apps/sim/lib/copilot/chat/selection-context.test.ts b/apps/sim/lib/copilot/chat/selection-context.test.ts new file mode 100644 index 00000000000..ee62399dbc0 --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildFileSelectionLabel, + buildTableSelectionLabel, + fileNameFromSelectionLabel, + selectionKey, + tableNameFromSelectionLabel, +} from './selection-context' + +describe('buildFileSelectionLabel', () => { + it('renders a line range', () => { + expect(buildFileSelectionLabel('notes.md', 12, 40)).toBe('notes.md:12-40') + }) + + it('renders a single line when start equals end', () => { + expect(buildFileSelectionLabel('notes.md', 12, 12)).toBe('notes.md:12') + }) + + it('renders a single line when only start is known', () => { + expect(buildFileSelectionLabel('notes.md', 12)).toBe('notes.md:12') + }) + + it('falls back to just the file name with no line info', () => { + expect(buildFileSelectionLabel('notes.md')).toBe('notes.md') + }) + + it('appends the disambiguation key when provided', () => { + expect(buildFileSelectionLabel('notes.md', 12, 40, 'k3f9')).toBe('notes.md:12-40 #k3f9') + expect(buildFileSelectionLabel('notes.md', undefined, undefined, 'k3f9')).toBe('notes.md #k3f9') + }) +}) + +describe('buildTableSelectionLabel', () => { + it('pluralizes rows and omits columns for whole-row selections', () => { + expect(buildTableSelectionLabel('Sales', 5)).toBe('Sales (5 rows)') + expect(buildTableSelectionLabel('Sales', 1)).toBe('Sales (1 row)') + }) + + it('includes column count for a cell range', () => { + expect(buildTableSelectionLabel('Sales', 5, 3)).toBe('Sales (5 rows, 3 cols)') + expect(buildTableSelectionLabel('Sales', 2, 1)).toBe('Sales (2 rows, 1 col)') + }) + + it('appends the disambiguation key when provided', () => { + expect(buildTableSelectionLabel('Sales', 2, undefined, 'k3f9')).toBe('Sales (2 rows #k3f9)') + expect(buildTableSelectionLabel('Sales', 2, 3, 'k3f9')).toBe('Sales (2 rows, 3 cols #k3f9)') + }) +}) + +describe('selectionKey', () => { + it('is deterministic and order-independent', () => { + expect(selectionKey(['r1', 'r2', 'r3'])).toBe(selectionKey(['r3', 'r1', 'r2'])) + }) + + it('differs for distinct id sets of the same size', () => { + expect(selectionKey(['r1', 'r2'])).not.toBe(selectionKey(['r3', 'r4'])) + }) +}) + +describe('selection-label name recovery', () => { + it('strips the line suffix from a file selection label', () => { + expect(fileNameFromSelectionLabel('notes.md:12-40')).toBe('notes.md') + expect(fileNameFromSelectionLabel('notes.md')).toBe('notes.md') + }) + + it('strips the disambiguation key and line suffix from a file selection label', () => { + expect(fileNameFromSelectionLabel('notes.md:12-40 #k3f9')).toBe('notes.md') + expect(fileNameFromSelectionLabel('notes.md #k3f9')).toBe('notes.md') + }) + + it('strips rows/cols/key suffix from a table selection label', () => { + expect(tableNameFromSelectionLabel('Sales (2 rows)')).toBe('Sales') + expect(tableNameFromSelectionLabel('Sales (2 rows, 3 cols #k3f9)')).toBe('Sales') + expect(tableNameFromSelectionLabel('Sales (5 rows #ab12)')).toBe('Sales') + }) +}) diff --git a/apps/sim/lib/copilot/chat/selection-context.ts b/apps/sim/lib/copilot/chat/selection-context.ts new file mode 100644 index 00000000000..8f7e7e0422b --- /dev/null +++ b/apps/sim/lib/copilot/chat/selection-context.ts @@ -0,0 +1,109 @@ +/** + * Shared bounds and label helpers for selection-scoped chat contexts + * (`file_selection`, `table_selection`). Kept free of server-only imports so + * both the client producers (file/table viewers) and the server validator / + * resolver can consume the same limits and formatting. + */ + +import { truncate } from '@sim/utils/string' + +/** + * Max characters of selected file text carried inline on a `file_selection`. + * This is the ceiling on the FINAL serialized string, matched by the server + * schema's `.max(...)`; always truncate through {@link truncateSelectionText} + * so the trailing ellipsis can't push the value past this bound. + */ +export const MAX_FILE_SELECTION_TEXT_LENGTH = 20_000 + +/** Max rows referenced by a single `table_selection`. */ +export const MAX_TABLE_SELECTION_ROWS = 500 + +/** Max columns referenced by a `table_selection` cell range. */ +export const MAX_TABLE_SELECTION_COLUMNS = 200 + +/** Length of the ellipsis {@link truncate} appends when it shortens a string. */ +const TRUNCATE_SUFFIX_LENGTH = 3 + +/** + * Truncates selected file text so the RESULT (including the appended ellipsis) + * never exceeds {@link MAX_FILE_SELECTION_TEXT_LENGTH} — keeping the client + * payload within the server schema bound, which otherwise rejects the whole + * chat request. + */ +export function truncateSelectionText(text: string): string { + return truncate(text, MAX_FILE_SELECTION_TEXT_LENGTH - TRUNCATE_SUFFIX_LENGTH) +} + +/** + * Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40`, + * `notes.md:12`, or just `notes.md` when no line range is known. An optional + * trailing `key` (from {@link selectionKey}) disambiguates two distinct passages + * that share the same line range — without it their labels collide and the + * second chip is dropped (chips are keyed by their `@label`). Kept ASCII so the + * label survives being inserted as an inline mention token in the chat input. + */ +export function buildFileSelectionLabel( + fileName: string, + startLine?: number, + endLine?: number, + key?: string +): string { + const suffix = key ? ` #${key}` : '' + if (!startLine) return `${fileName}${suffix}` + const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}` + return `${fileName}:${range}${suffix}` +} + +/** + * Short, deterministic key for a set of ids — same ids (any order) yield the + * same key. Used to disambiguate table-selection labels so two distinct + * selections of the same size don't collapse to one chip (chips are keyed by + * their `@label`, and a collision would drop the second context). + */ +export function selectionKey(ids: string[]): string { + const joined = [...ids].sort().join(',') + let hash = 0 + for (let i = 0; i < joined.length; i++) { + hash = (Math.imul(hash, 31) + joined.charCodeAt(i)) | 0 + } + return (hash >>> 0).toString(36) +} + +/** + * Builds the chip label for a table selection, e.g. `Sales (5 rows #k3f9)` or, + * for a cell range, `Sales (5 rows, 3 cols #k3f9)`. The trailing `key` (from + * {@link selectionKey}) keeps distinct same-size selections from sharing a + * label. ASCII-only for the same reason as {@link buildFileSelectionLabel}. + */ +export function buildTableSelectionLabel( + tableName: string, + rowCount: number, + columnCount?: number, + key?: string +): string { + const rows = `${rowCount} ${rowCount === 1 ? 'row' : 'rows'}` + const suffix = key ? ` #${key}` : '' + if (!columnCount) return `${tableName} (${rows}${suffix})` + const cols = `${columnCount} ${columnCount === 1 ? 'col' : 'cols'}` + return `${tableName} (${rows}, ${cols}${suffix})` +} + +/** + * Recovers the bare file name from a {@link buildFileSelectionLabel} label by + * stripping the trailing ` #key` disambiguator (when present) and the + * `:line` / `:start-end` range. Co-located with the builder so the two formats + * can't drift apart. Used to title the resource tab (the whole file) rather than + * the selection. + */ +export function fileNameFromSelectionLabel(label: string): string { + return label.replace(/ #[0-9a-z]+$/, '').replace(/:\d+(?:-\d+)?$/, '') +} + +/** + * Recovers the bare table name from a {@link buildTableSelectionLabel} label by + * stripping the trailing ` (N rows[, M cols])` suffix. Co-located with the + * builder for the same reason as {@link fileNameFromSelectionLabel}. + */ +export function tableNameFromSelectionLabel(label: string): string { + return label.replace(/\s*\(\d+ rows?(?:, \d+ cols?)?(?: #[0-9a-z]+)?\)$/, '') +} diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index f5bb0cc483d..981036748c5 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -105,6 +105,7 @@ export const STORAGE_KEYS = { LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed', WORKSPACE_RECENCY: 'sim_workspace_recency', MOTHERSHIP_HANDOFF: 'sim_mothership_handoff', + MOTHERSHIP_PENDING_CONTEXTS: 'sim_mothership_pending_contexts', } as const export class WorkspaceRecencyStorage { @@ -380,3 +381,59 @@ export class MothershipHandoffStorage { return BrowserStorage.removeItem(MothershipHandoffStorage.KEY) } } + +interface PendingContextsEntry { + contexts: ChatContext[] + workspaceId: string + timestamp: number +} + +/** + * Accumulates context chips to seed into the Chat input when it next mounts, + * used by the highlight-to-chat action when no chat is currently listening + * (e.g. the user is on the standalone Files/Tables page). Unlike + * {@link MothershipHandoffStorage}, this never auto-sends — it only pre-fills + * the input with reference chips, matching the passive "add to chat" behavior. + * Multiple adds accumulate; `consume` drains and clears the list. + */ +export class MothershipPendingContextStorage { + private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_PENDING_CONTEXTS + + /** Appends a context for the workspace, replacing any entry from another workspace. */ + static store(context: ChatContext, workspaceId: string): boolean { + if (!workspaceId) return false + const existing = BrowserStorage.getItem( + MothershipPendingContextStorage.KEY, + null + ) + const contexts = + existing && existing.workspaceId === workspaceId ? [...existing.contexts, context] : [context] + return BrowserStorage.setItem(MothershipPendingContextStorage.KEY, { + contexts, + workspaceId, + timestamp: Date.now(), + }) + } + + /** + * Retrieves and clears the pending contexts for `workspaceId`. Entries owned + * by another workspace are left untouched; stale entries past `maxAge` are + * dropped. + * @param maxAge - Maximum age in milliseconds (default: 5 minutes) + */ + static consume(workspaceId: string, maxAge: number = 5 * 60 * 1000): ChatContext[] { + const data = BrowserStorage.getItem( + MothershipPendingContextStorage.KEY, + null + ) + if (!data) return [] + if (data.workspaceId !== workspaceId) return [] + MothershipPendingContextStorage.clear() + if (!data.timestamp || Date.now() - data.timestamp > maxAge) return [] + return Array.isArray(data.contexts) ? data.contexts : [] + } + + static clear(): boolean { + return BrowserStorage.removeItem(MothershipPendingContextStorage.KEY) + } +} diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 9143a73c5b5..50b13070028 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -45,3 +45,36 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) }) return consumed } + +/** + * Custom-event name used to attach a context chip to the Mothership chat input + * WITHOUT sending a message. The mounted chat input listens for this and inserts + * the chip, leaving the user to type their prompt and send when ready. + */ +export const MOTHERSHIP_ADD_CONTEXT_EVENT = 'mothership-add-context' + +export interface MothershipAddContextDetail { + /** The context to attach as a chip in the input. */ + context: ChatContext +} + +/** + * Dispatches a passive "add this context chip" request to a mounted Mothership + * chat input. Producers (the highlight-to-chat action in the file/table viewers) + * call this; the mounted input listens for {@link MOTHERSHIP_ADD_CONTEXT_EVENT} + * and `preventDefault`s to claim it. + * + * @returns `true` when a mounted input consumed it, `false` when none was + * listening — callers fall back to persisting the context for the next chat + * mount (see `MothershipPendingContextStorage`). + */ +export function addMothershipContext(context: ChatContext): boolean { + const consumed = !window.dispatchEvent( + new CustomEvent(MOTHERSHIP_ADD_CONTEXT_EVENT, { + detail: { context }, + cancelable: true, + }) + ) + logger.info('Dispatched mothership add-context event', { kind: context.kind, consumed }) + return consumed +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 41eedd066f8..1c5d998e5a5 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1150,6 +1150,36 @@ export async function getRowById( } } +/** + * Fetches the `data` payloads for a set of rows by id, scoped to a table and + * workspace. Returns lightweight `{ id, data }` records (no executions) in the + * order the ids were requested, silently skipping ids that don't resolve. Used + * to materialize a `table_selection` chat context server-side so the agent gets + * fresh, authoritative cell values instead of trusting client-sent copies. + */ +export async function getRowsByIds( + tableId: string, + rowIds: string[], + workspaceId: string +): Promise> { + const uniqueIds = Array.from(new Set(rowIds)) + if (uniqueIds.length === 0) return [] + + const results = await db + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + inArray(userTableRows.id, uniqueIds), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + + const byId = new Map(results.map((r) => [r.id, r.data as RowData])) + return uniqueIds.filter((id) => byId.has(id)).map((id) => ({ id, data: byId.get(id) as RowData })) +} + /** Internal: thrown inside `db.transaction` to roll back when the executions * guard rejects a write. The outer `.catch` translates it into a `null` return. */ class GuardRejected extends Error { diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index dbf91a502a4..815ad7a06a6 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -24,7 +24,29 @@ export type ChatContext = | { kind: 'workflow_block'; workflowId: string; blockId: string; label: string } | { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'table'; tableId: string; label: string } + | { + kind: 'table_selection' + tableId: string + label: string + /** Ids of the selected rows. Always present (materialized from the grid selection). */ + rowIds: string[] + /** + * Ids of the selected columns. Present only for a spreadsheet-style cell + * range; absent when whole rows are selected. + */ + columnIds?: string[] + } | { kind: 'file'; fileId: string; label: string } + | { + kind: 'file_selection' + fileId: string + label: string + /** The literal selected text, carried inline so the agent sees the exact passage. */ + text: string + /** 1-based inclusive line range of the selection, when the source has lines. */ + startLine?: number + endLine?: number + } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } | { kind: 'scheduledtask'; scheduleId: string; label: string }