Skip to content

Commit cd50a44

Browse files
feat(chat): highlight-to-chat — reference file/table selections in Chat (#6087)
* feat(chat): highlight-to-chat for file and table selections Adds an IDE-style "add to chat" affordance so the Sim agent can reference an exact passage of a file or a specific set of table rows/cells instead of a whole resource. - Two new ChatContext kinds: file_selection (inline selected text + line range) and table_selection (authoritative row ids, optional column ids; the server re-fetches current rows by id). Chip registry, client serializer, boundary contract, and server resolver all extend along their existing switch(kind) seams. - Producers: Monaco context menu, Tiptap bubble menu, and the table grid context menu, all using the Sim Chat block icon. Adding a selection opens the split slideover (chat + resource) with the chip dropped straight into the input; from a standalone Files/Tables page the context is stashed and drained on chat mount. - Cmd+C / Cmd+V: a selection copied from a file/table rides a custom text/x-sim-selection clipboard MIME and pastes into chat as the same reference chip; the chat input round-trips a sole selection chip on copy/cut too. - Guards: selection payloads are length/row/column bounded and truncated within the schema bound; labels carry a deterministic key so distinct same-size selections don't collide; a shared resource tab closes only once no remaining chip references it; stale cell-range column ids drop the context rather than dumping the full table. * fix(chat): widen selection code fence so embedded backticks can't truncate it Cursor: resolveFileSelectionResource wrapped the selected passage in a fixed ``` fence, so a selection that itself contained a fenced code block closed the outer fence early and the agent received a truncated snippet. The fence is now one backtick longer than the longest backtick run in the content (floored at three), matching CommonMark's close rule. * fix(chat): carry the selection chip on a column-header copy Cursor: a column-header Cmd+C always took the async paged clipboard path, which replaces the whole clipboard with text/plain only and can't carry a custom MIME - so pasting a column copy into Chat couldn't rebuild the table_selection chip that Add-to-chat produces for the same selection. When every row is loaded and within the chat-selection cap, the column copy now does a synchronous event write so the scoped table_selection rides alongside text/plain (mirroring the row-'some' and cell-range paths); oversized/partially-loaded columns keep the async plain-text path. * refactor(chat): clean up highlight-to-chat selections Correctness: - Stop reporting fabricated line numbers for rich-markdown selections. `doc.textBetween` counts ProseMirror block boundaries, not markdown source lines, so the chip label and the agent prompt both claimed line ranges that don't exist in the file. Line info is now emitted only by Monaco. - Bound a table_selection's rendered markdown by characters, not just row and column counts — 500 wide rows dwarfed the 20k-char file-selection budget. Rows are emitted until the budget is spent, and the content says what was omitted. - Complete `areContextsEqual` for both selection kinds, so re-adding the same selection dedupes while a different passage of an already-referenced file registers as new. Consistency: - Carry the resource display name on the context instead of recovering it by regex from the chip label, deleting fileNameFromSelectionLabel and tableNameFromSelectionLabel. - Replace the user-visible `#k3f9` hash disambiguator with a readable ordinal applied at insert time (`Sales (3 rows) (2)`), via a shared prepareContextForInsert used by both the add-to-chat and paste paths. - Fold MothershipPendingContextStorage into MothershipHandoffStorage as a chip-only handoff (optional message), removing the parallel storage class, the second drain effect, and its StrictMode guard ref. - Replace `window.location.assign` with `router.push`, matching the existing "Troubleshoot in Chat" handoff. - Collapse three near-duplicate synchronous copy branches in table-grid into shared buildTableSelectionContext / writeLoadedRowsWithChip helpers, also reused by the add-to-chat handler. - Trim multi-paragraph inline comments to TSDoc stating each reason once. Tests: new coverage for the character budget, the label ordinal, selection equality, and chip-only handoff accumulation; each verified to fail when its fix is reverted. * refactor(chat): tighten table copy fallback and helper placement - writeLoadedRowsWithChip now requires a chip to carry; with none it falls through to the canonical paged path (preserving its row loading and truncation notice) instead of doing a bare synchronous write. - Reuse selectedColumnIds in the context-menu memo. - Restore resolveTableSelectionResource's TSDoc, orphaned when renderTableCell was extracted between the doc and its function. * fix(chat): apply chip handoffs as one batch; widen table copy chip path Cursor Bugbot: a multi-context chip handoff dispatched one event per context, and insertContextChip resolved label collisions against selectedContexts read through a ref that only refreshes on render. Each dispatch therefore saw the same stale list, so a second same-label selection was never ordinalized and addContext dropped it while its @token still landed in the text. The event now carries the whole batch and insertContextChips threads each resolved context forward as it goes. Greptile: an explicit multi-row ('some') selection no longer requires every selected row to be loaded before taking the chip-carrying sync path. That gate assumed the paged fall-through would copy more, but its loadRows returns rowsRef.current unchanged for 'some' — the same rows, minus the chip. Renamed the parameter to to say what it actually gates on. Remaining chip-less cases are inherent to the async Clipboard API, which replaces the whole clipboard and cannot hold a custom MIME: a filtered select-all (must page in more rows) and selections past the 500-row chip cap. 'Add to chat' covers both — it is not gesture-bound and drains to the cap. * fix(chat): distinguish a line-less file-selection label from the whole file Cursor Bugbot: a rich-markdown selection labelled itself with the bare file name, which is exactly the whole-file chip's label. Menu-driven inserts reject any context whose label is already taken (isContextAlreadySelected closes the menu silently), so once a markdown selection was attached, mentioning that same file was quietly dropped. One-way: the reverse order works because programmatic inserts ordinalize through prepareContextForInsert. Fallen out of dropping the fabricated line range from this editor — with no range left, the label collapsed onto the file name. Fixed at the single source: buildFileSelectionLabel now returns 'notes.md (selection)' when there is no line range, so it stays honest about location while remaining distinguishable. * fix(chat): don't revive an aged-out chip handoff when accumulating Cursor Bugbot: pendingContexts merged a prior chip-only handoff without checking its age, while store stamps a fresh timestamp on every write. An abandoned handoff that had already passed max-age would therefore ride along on the next 'Add to chat' and fire on the following navigation as if current. Introduced by the accumulation behavior added earlier in this PR. Applied the same freshness bar consume uses, and hoisted the 60s window into a single MAX_AGE_MS constant so the two paths cannot drift. * fix(chat): reference every selected row in a table chip, not just loaded ones Cursor Bugbot: for a 'some' row selection the chip's rowIds came from the loaded-page intersection (currentRows filtered by the selection) rather than rowSel.ids. The chip carries ids and the server re-fetches them via getRowsByIds, so a selected row that simply had not been paged into the grid was silently dropped from the agent's context — select 600 rows with 200 loaded and the agent saw 200. Add to chat had the same loaded-only narrowing through contextMenuRowIds. Both now send the full selection, still bounded by MAX_TABLE_SELECTION_ROWS. Only the pasted text stays limited to loaded rows, which is inherent — there are no cell values to serialize for a row that has not been fetched. * docs(chat): scope the table copy 'complete' comment to the text path It read as though the whole selection were complete when ids are unloaded, which is now only true of the serialized text — the chip deliberately carries every selected id for the server to re-fetch. * fix(chat): compare selection ids as sets, not sequences Cursor Bugbot: sameIds compared rowIds/columnIds by index, but a table selection's ids iterate in click order (they come from a Set), so the same rows picked in a different order — or reached via a cell range rather than the gutter — compared unequal. prepareContextForInsert then added a second ordinalized chip pointing at rows already referenced instead of no-opping. More reachable since the previous commit started sourcing rowIds from rowSel.ids directly, where insertion order tracks the user's clicks. * fix(chat): enforce the table selection budget over the whole rendered content Cursor Bugbot: the budget subtracted only the header and divider before packing rows, then prepended the 'Selected ...' prose and the newlines afterward, so the final content could exceed MAX_TABLE_SELECTION_CONTENT_LENGTH whenever the last accepted row left less slack than the prefix needed. The cap the TSDoc promises was not actually enforced. The prior test passed while missing this: its rows were wide enough that packing stopped far short of the limit, so the boundary was never exercised. Replaced with rows sized to fill the budget almost exactly, asserting both that the content stays within the cap and that it still approaches it (so the assertion can't pass by emitting an empty table). Also capitalize Chat in the three 'Add to Chat' menu labels, matching the constitution's module naming and the existing 'Fix in Chat' / 'Troubleshoot in Chat' UI strings. * fix(chat): don't swallow a paste whose selection chip is already attached Cursor Bugbot: the selection-paste path called preventDefault before prepareContextForInsert, so when that returned null (the same selection is already a chip) the handler returned having claimed the event — no chip inserted and no text/plain pasted either. Cmd+V did nothing. preventDefault now waits until there is a chip to insert; a duplicate falls through to the plain-text paste below, which is the reasonable reading of the gesture. * fix(chat): derive the budget reserve from the same clause it reserves for Cursor Bugbot: worstCaseSizeClause built its own string that omitted the row/rows word the real clause always carries, so the reserve ran ~5 characters short and a tightly packed selection could still exceed MAX_TABLE_SELECTION_CONTENT_LENGTH. A bug in the previous fix, from duplicating the format instead of sharing it. Replaced with one sizeClause(shown, omitted) used for both the up-front reserve and the final prose, so the two cannot describe the count differently. The reserve passes (rows.length, rows.length) — max digits on both counts and the plural forced — which is an upper bound on any real clause. The earlier tight-packing test could not see this: a single cell width leaves whatever remainder it leaves, and 100 left more than 5 characters. Added a sweep over widths 60-75 that collects overflows so a failure names the width; it catches the reported bug at width 74 (20002 vs 20000). * fix(chat): align MothershipChat's onContextRemove with the surface contract The remaining-contexts argument was added to ChatSurfaceContextValue but not to MothershipChat's own prop type, which forwards straight into it. Nothing passes the handler there today so it typechecked (fewer params is assignable), but the two declarations of the same wiring disagreed. Does not change behavior: home still wires the remove handler only to the empty-state surface, as it did before this PR. * refactor(chat): apply cleanup pass findings emcn design: the table context menu built its Add-to-Chat label in the parent and never pluralized it, so right-clicking a single row read 'Add rows to Chat' directly above 'Delete row'. Derive it inside ContextMenu from selectedRowCount like every sibling label, with an addToChatCellScoped boolean mirroring workflowCellScoped. Also more correct: selectedRowCount accounts for a select-all beyond the loaded page, which contextMenuRowIds.length does not. callbacks: drop two useCallback wrappers whose identity nothing observes — both handleAddSelectionToChat handlers feed unmemoized components (one through an inline arrow). buildSelectionContext stays wrapped; it is a real dep of the copy-bridge effect. comments: fold a duplicated rationale paragraph in handleAddSelectionToChat left by two commits stacking, drop a {@link MothershipHandoff} that resolves to nothing (the type is not imported there), and trim a rowIds doc that restated its own type. Skipped, deliberately: the effects pass proposed moving buildContext out of useSelectionCopyBridge's deps behind a latest-ref. buildSelectionContext is already stable, so churn is near-zero, and it would leave two sibling useCallbacks purposeless. * fix(chat): don't attach a selection chip to a copy from a nested input Cursor Bugbot: a copy from a field inside the editor — Monaco's find box being the common one — bubbles to the container while the document still holds a highlight, so the bridge attached the editor selection to text the user never copied. Chat paste then prefers the custom MIME and inserts a reference chip instead of the search term. Skips INPUT targets only. Copying the table grid's INPUT/TEXTAREA guard would have suppressed the chip on the main copy path this hook exists for: Monaco's own editing surface is a hidden textarea, unlike the grid's cell editors, which really are form fields. Tests cover both directions — chip attached from the textarea surface, skipped from a nested input — and were verified to fail against the missing guard and against the INPUT+TEXTAREA variant. * fix(chat): persist the source names a selection chip renders from Cursor Bugbot: fileName/tableName were mapped into the optimistic message and accepted by the API schema, but PersistedMessageContext, buildPersistedUserMessage and toDisplayContexts never carried them. After a reload a file_selection chip fell back to its label for getDocumentIcon — and the label carries a location suffix ('notes.md:12-40'), so extension detection broke. Persists only the two names the display path reads. The rest of the payload (text, rowIds, columnIds, line numbers) stays unpersisted on purpose: it exists to resolve the selection server-side at send time, is never re-read when rendering a past message, and would put a selection-sized blob — up to the 20k char cap — in every stored message. Test asserts both halves of that, and was verified to fail with the mapping removed. * refactor(chat): remove a needless alias and correct an eslint-disable reason Self-audit for shortcuts, prompted by 'nothing hacky': - handlePaste kept `const prepared = preparedSelection`, an alias added only to avoid renaming two downstream lines. Uses the real name now. - The home.tsx drain's eslint-disable claimed handleContextAdd is 'a stable body function'. It is a body function, so it is a NEW value every render — the justification was false. Replaced with the actual reason: it is omitted to keep the drain one-shot, and doing so is harmless because consume() clears atomically, so a re-run would find nothing. Audited the rest of the diff for suppressions, casts and swallowed errors. The three catch blocks are documented graceful degradations with explicit fallbacks (row-drain failure, browsers rejecting a custom clipboard MIME mid-gesture, malformed clipboard JSON); the one double cast is a DataTransfer stub in a test. * fix(chat): bound the sync copy path by the text limit, not the chip cap Cursor Bugbot: writeLoadedRowsWithChip bailed once loaded rows exceeded MAX_TABLE_SELECTION_ROWS, but buildTableSelectionContext already slices rowIds to that cap. So selecting more than 500 loaded rows fell through to the async path, which cannot carry a custom MIME, and silently lost the chip — while Add to Chat on the very same selection still produced a 500-row chip. The two limits govern different things: the chip's cap is how many rows a table_selection can reference, the text's is TABLE_LIMITS.MAX_COPY_ROWS. Gate on the latter. Past it the paged path still takes over, because it owns truncation and the accompanying notice. * refactor(tables): extract selection-to-chip helpers into utils so they are testable Follow-up I owed on the previous round: the copy path's eligibility rule and the context builder were module-private in a ~4,600-line component with no test file, so the last two fixes to them were reasoned rather than covered — and both were wrong on the first attempt. Moves selectedColumnIds and buildTableSelectionContext to the existing table-grid/utils.ts (which already owns RowSelection, DisplayColumn and getColumnId), and extracts the copy decision as canWriteRowsWithChip. writeLoadedRowsWithChip keeps only the clipboard and toast effects, so the pure rule can be tested without a DOM. utils.ts stays free of side effects. New utils.test.ts covers the two limits that were conflated — a selection past the chip cap still qualifies (the context caps its own rowIds), one past MAX_COPY_ROWS defers to the paged path — plus the all-columns collapse and both caps. Verified to fail against the old chip-cap gate. * fix(chat): scope selections to the columns actually picked, and stop under-counting rows Three findings from one Bugbot round. Hidden columns widened cell ranges (reported twice). buildTableSelectionContext and contextMenuColumnIds collapsed a range to an open scope when it covered 'every column', comparing against displayColumns.length — which drops hidden columns AND expands workflow groups, so it never meant 'the whole schema'. Selecting every visible column therefore cleared columnIds and the server re-fetched columns the user had hidden. The collapse is removed rather than re-based on a schema count: no count available to a caller describes the schema, and an explicit column list is what the user actually selected. totalColumnCount is gone from the signature. Add-to-chat label undercounted rows. The menu derived its count from selectedRowCount (loaded rows only) while the chip was built from the full rowSel.ids set, so the label could promise fewer rows than were sent. Both now read one addToChatRowIds memo, with the count passed through explicitly since it legitimately differs from the count the delete/run labels use. Monaco line range was off by one. A full-line highlight ends at column 1 of the FOLLOWING line, so endLineNumber named a line that contributed no text — the chip label and the agent prompt both claimed an extra line. The collapse test I added last round asserted the buggy behavior as correct; it now pins the opposite, and fails if the collapse returns. * fix(tables): cap the Add-to-Chat label at the rows a chip can carry Cursor Bugbot: last round's fix for the label undercounting rows introduced the opposite error. addToChatRowCount passed the raw selection size, but buildTableSelectionContext caps rowIds at MAX_TABLE_SELECTION_ROWS, so a 2,000-row selection advertised 2,000 while the chip referenced 500 — breaking the same invariant the fix claimed to establish. Routes the count through a chipRowCount helper next to the builder that applies the cap, so the label cannot drift from the payload again. utils.test.ts now asserts the invariant directly rather than the formula: across 1, 42, 500, 750 and 50,000 requested rows, chipRowCount equals the rowIds length the context actually carries. Verified to fail if the cap is dropped. * fix(chat): stop a removed chip lingering when its label prefixes another Cursor Bugbot: the mention sync tests each label with a lookahead that rejects only word characters, so '-', ')' and space all let a shorter label match INSIDE a longer token. '@notes.md:12' matches within '@notes.md:12-40', and '@sales (3 rows)' within '@sales (3 rows) (2)'. Deleting the shorter chip left its context attached, and it was still sent with the message. The label class is pre-existing, but this PR made it routine: line ranges and uniqueContextLabel ordinals generate prefix pairs for any two selections of the same file or table. Fixed at the sync rather than by reshaping labels to dodge the prefix — a label format chosen to avoid a matcher bug would just relocate it. Contexts are now tested longest-label-first, and each matched token is blanked before shorter labels are tested, so every context is judged against text its own token owns. Blanked in place, not removed, so the (^|\s) boundary of whatever sits next to it is preserved; prev order is still what's returned. Shared with the workflow copilot input, so tests cover both directions: the two prefix pairs are dropped when only the longer token remains, both survive when both tokens are present, order is preserved, and trailing punctuation after a mention still keeps its chip. * fix(chat): include the line range in file-selection equality Cursor Bugbot: areContextsEqual compared only fileId and text for file_selection, while the comment directly above claimed equality was the selected range. A line that occurs twice in a file — a repeated import, a closing brace — highlighted at both places produced identical text, so prepareContextForInsert called the second a duplicate and dropped its chip, even though the labels (notes.md:12 vs notes.md:50) were plainly different. Comparing startLine/endLine as well makes the code match what the comment promised. The rich-markdown editor omits the range, so both sides are undefined there and identical text still dedupes — correct, since two identical passages are genuinely indistinguishable without line numbers. Tests cover both: distinct ranges stay distinct, an exact repeat still dedupes, and the no-line-number path still dedupes. Verified the first fails against text-only equality. * fix(tables): drain past the cap so exclusions can't shrink a select-all chip Cursor Bugbot: for a gutter select-all the menu count comes from selectedRowCount (capped), but the chip was built by loading exactly MAX_TABLE_SELECTION_ROWS and filtering exclusions AFTER. Any excluded row inside that prefix left the chip short of the advertised count. Third appearance of the same invariant — label vs payload — this time in the select-all path specifically, which the earlier fixes did not touch. Loads the cap plus the exclusion count, which covers the worst case where every exclusion falls inside the prefix, so the filtered result still reaches the cap whenever the table has the rows. Extracted as drainTargetForChip so the compensation is stated and tested rather than an inline arithmetic detail. --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent d89ab4a commit cd50a44

40 files changed

Lines changed: 2533 additions & 70 deletions

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
DropdownMenuShortcut,
99
DropdownMenuTrigger,
1010
} from '@sim/emcn'
11-
import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
11+
import { Blimp, Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
1212
import { Scissors } from 'lucide-react'
1313

1414
interface EditorContextMenuProps {
@@ -23,6 +23,8 @@ interface EditorContextMenuProps {
2323
onPaste: () => void
2424
onSelectAll: () => void
2525
onFind: () => void
26+
/** Adds the current selection to Chat as a reference. Omit to hide the item. */
27+
onAddToChat?: () => void
2628
}
2729

2830
export function EditorContextMenu({
@@ -37,6 +39,7 @@ export function EditorContextMenu({
3739
onPaste,
3840
onSelectAll,
3941
onFind,
42+
onAddToChat,
4043
}: EditorContextMenuProps) {
4144
return (
4245
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -60,6 +63,15 @@ export function EditorContextMenu({
6063
sideOffset={2}
6164
onCloseAutoFocus={(e) => e.preventDefault()}
6265
>
66+
{onAddToChat && (
67+
<>
68+
<DropdownMenuItem disabled={!hasSelection} onSelect={onAddToChat}>
69+
<Blimp />
70+
Add to Chat
71+
</DropdownMenuItem>
72+
<DropdownMenuSeparator />
73+
</>
74+
)}
6375
{canEdit && (
6476
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
6577
<Scissors />

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { Blimp } from '@sim/emcn/icons'
23
import { posToDOMRect } from '@tiptap/core'
34
import { PluginKey } from '@tiptap/pm/state'
45
import type { Editor } from '@tiptap/react'
@@ -54,6 +55,8 @@ interface EditorBubbleMenuProps {
5455
editor: Editor
5556
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
5657
scrollContainerRef: React.RefObject<HTMLDivElement | null>
58+
/** Adds the current selection to Chat as a reference. Omit to hide the action. */
59+
onAddToChat?: () => void
5760
}
5861

5962
/**
@@ -62,7 +65,11 @@ interface EditorBubbleMenuProps {
6265
* live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
6366
* stays correct without re-rendering the editor on every transaction.
6467
*/
65-
export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) {
68+
export function EditorBubbleMenu({
69+
editor,
70+
scrollContainerRef,
71+
onAddToChat,
72+
}: EditorBubbleMenuProps) {
6673
const [linkValue, setLinkValue] = useState<string | null>(null)
6774
const linkInputRef = useRef<HTMLInputElement>(null)
6875
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
243250
</>
244251
) : (
245252
<>
253+
{onAddToChat && (
254+
<>
255+
<ToolbarButton
256+
icon={Blimp}
257+
label='Add to Chat'
258+
isActive={false}
259+
onClick={onAddToChat}
260+
/>
261+
<ToolbarDivider />
262+
</>
263+
)}
246264
<ToolbarButton
247265
icon={Bold}
248266
label='Bold'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type { ComponentType, SVGProps } from 'react'
12
import { cn, Tooltip } from '@sim/emcn'
2-
import type { LucideIcon } from 'lucide-react'
33

44
interface ToolbarButtonProps {
5-
icon: LucideIcon
5+
/** Any SVG icon component — Lucide icons and `@sim/emcn/icons` both satisfy this. */
6+
icon: ComponentType<SVGProps<SVGSVGElement>>
67
label: string
78
shortcut?: string
89
isActive?: boolean

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ import type { Editor } from '@tiptap/react'
1212
import { EditorContent, useEditor } from '@tiptap/react'
1313
import { useRouter } from 'next/navigation'
1414
import { useSession } from '@/lib/auth/auth-client'
15+
import {
16+
buildFileSelectionLabel,
17+
truncateSelectionText,
18+
} from '@/lib/copilot/chat/selection-context'
1519
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1620
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
1721
import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title'
1822
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
23+
import { useAddToChat } from '@/hooks/use-add-to-chat'
1924
import type { SaveStatus } from '@/hooks/use-autosave'
2025
import { useFileContentSource } from '@/hooks/use-file-content-source'
26+
import type { ChatContext } from '@/stores/panel'
2127
import { PreviewLoadingFrame } from '../preview-shared'
2228
import { useEditableFileContent } from '../use-editable-file-content'
29+
import { useSelectionCopyBridge } from '../use-selection-copy-bridge'
2330
import {
2431
announceAgentApplying,
2532
clearAgentApplying,
@@ -1124,6 +1131,36 @@ export function LoadedRichMarkdownEditor({
11241131
[]
11251132
)
11261133

1134+
const addToChat = useAddToChat()
1135+
/**
1136+
* No line range: this editor renders a ProseMirror document, whose block
1137+
* boundaries do not correspond to markdown source lines (blank lines between
1138+
* paragraphs, list markers, heading prefixes and fenced blocks all shift the
1139+
* real line). Reporting a derived count would label the chip — and prompt the
1140+
* agent — with line numbers that don't exist in the file.
1141+
*/
1142+
const buildSelectionContext = useCallback((): ChatContext | null => {
1143+
if (!editor) return null
1144+
const { from, to } = editor.state.selection
1145+
if (from === to) return null
1146+
const text = editor.state.doc.textBetween(from, to, '\n')
1147+
if (!text.trim()) return null
1148+
return {
1149+
kind: 'file_selection',
1150+
fileId: file.id,
1151+
fileName: file.name,
1152+
label: buildFileSelectionLabel(file.name),
1153+
text: truncateSelectionText(text),
1154+
}
1155+
}, [editor, file.id, file.name])
1156+
1157+
const handleAddSelectionToChat = () => {
1158+
const context = buildSelectionContext()
1159+
if (context) addToChat(context)
1160+
}
1161+
1162+
useSelectionCopyBridge(containerRef, buildSelectionContext)
1163+
11271164
// Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet
11281165
// seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held
11291166
// until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder
@@ -1135,7 +1172,13 @@ export function LoadedRichMarkdownEditor({
11351172
ref={containerRef}
11361173
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
11371174
>
1138-
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
1175+
{editor && (
1176+
<EditorBubbleMenu
1177+
editor={editor}
1178+
scrollContainerRef={containerRef}
1179+
onAddToChat={handleAddSelectionToChat}
1180+
/>
1181+
)}
11391182
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
11401183
{editor && <LinkHoverCard editor={editor} />}
11411184
<input

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@ import type { OnMount } from '@monaco-editor/react'
55
import { cn } from '@sim/emcn'
66
import type { editor as MonacoEditorTypes } from 'monaco-editor'
77
import dynamic from 'next/dynamic'
8+
import {
9+
buildFileSelectionLabel,
10+
truncateSelectionText,
11+
} from '@/lib/copilot/chat/selection-context'
812
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
913
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
14+
import { useAddToChat } from '@/hooks/use-add-to-chat'
15+
import type { ChatContext } from '@/stores/panel'
1016
import { EditorContextMenu } from './editor-context-menu'
1117
import type { PreviewMode } from './file-viewer'
1218
import { PreviewPanel, resolvePreviewType } from './preview-panel'
1319
import { PreviewLoadingFrame } from './preview-shared'
1420
import { useEditableFileContent } from './use-editable-file-content'
21+
import { useSelectionCopyBridge } from './use-selection-copy-bridge'
1522

1623
const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [
1724
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
@@ -373,6 +380,38 @@ export const TextEditor = memo(function TextEditor({
373380

374381
const monacoLanguage = resolveMonacoLanguage(file)
375382
const monacoTheme = useMonacoTheme()
383+
const addToChat = useAddToChat()
384+
385+
const buildSelectionContext = useCallback((): ChatContext | null => {
386+
const editor = monacoEditorRef.current
387+
const sel = editor?.getSelection()
388+
const model = editor?.getModel()
389+
if (!editor || !sel || sel.isEmpty() || !model) return null
390+
const text = model.getValueInRange(sel)
391+
if (!text.trim()) return null
392+
const startLine = sel.startLineNumber
393+
// A full-line highlight ends at column 1 of the FOLLOWING line, so that line
394+
// contributed no text — reporting it would claim a range one line longer
395+
// than what was selected, in both the chip label and the agent's prompt.
396+
const endLine =
397+
sel.endColumn === 1 && sel.endLineNumber > startLine
398+
? sel.endLineNumber - 1
399+
: sel.endLineNumber
400+
return {
401+
kind: 'file_selection',
402+
fileId: file.id,
403+
fileName: file.name,
404+
label: buildFileSelectionLabel(file.name, startLine, endLine),
405+
text: truncateSelectionText(text),
406+
startLine,
407+
endLine,
408+
}
409+
}, [file.id, file.name])
410+
411+
const handleAddSelectionToChat = () => {
412+
const context = buildSelectionContext()
413+
if (context) addToChat(context)
414+
}
376415

377416
const {
378417
content,
@@ -394,6 +433,10 @@ export const TextEditor = memo(function TextEditor({
394433
})
395434
contentRef.current = content
396435

436+
// Enable once content has loaded — the container (and Monaco) only mount after
437+
// the `isContentLoading` early return below, so the bridge must (re-)attach then.
438+
useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading)
439+
397440
useEffect(() => {
398441
const editor = monacoEditorRef.current
399442
if (!editor) return
@@ -650,6 +693,10 @@ export const TextEditor = memo(function TextEditor({
650693
onClose={closeContextMenu}
651694
hasSelection={contextMenu.hasSelection}
652695
canEdit={!isEditorReadOnly}
696+
onAddToChat={() => {
697+
handleAddSelectionToChat()
698+
closeContextMenu()
699+
}}
653700
onCut={() => {
654701
monacoEditorRef.current?.focus()
655702
monacoEditorRef.current?.trigger(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createRef, type RefObject } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
8+
import type { ChatContext } from '@/stores/panel'
9+
import { useSelectionCopyBridge } from './use-selection-copy-bridge'
10+
11+
const selection: ChatContext = {
12+
kind: 'file_selection',
13+
fileId: 'f1',
14+
fileName: 'notes.md',
15+
label: 'notes.md:2-4',
16+
text: 'the exact passage',
17+
}
18+
19+
let container: HTMLDivElement
20+
let root: Root
21+
let containerRef: RefObject<HTMLDivElement | null>
22+
let buildContext: ReturnType<typeof vi.fn>
23+
24+
/**
25+
* Mirrors the editors this hook wraps: Monaco's editing surface is a hidden
26+
* textarea, and its find widget is a real input nested in the same container.
27+
*/
28+
function Host() {
29+
useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null)
30+
return (
31+
<div ref={containerRef}>
32+
<textarea id='editor-surface' />
33+
<input id='find-box' />
34+
</div>
35+
)
36+
}
37+
38+
/** Dispatches a bubbling copy from `id` and returns what was written. */
39+
function dispatchCopy(id: string): Record<string, string> {
40+
const written: Record<string, string> = {}
41+
const event = new Event('copy', { bubbles: true }) as ClipboardEvent
42+
Object.defineProperty(event, 'clipboardData', {
43+
value: {
44+
setData: (type: string, value: string) => {
45+
written[type] = value
46+
},
47+
},
48+
})
49+
act(() => {
50+
container.querySelector(`#${id}`)?.dispatchEvent(event)
51+
})
52+
return written
53+
}
54+
55+
describe('useSelectionCopyBridge', () => {
56+
beforeEach(() => {
57+
container = document.createElement('div')
58+
document.body.appendChild(container)
59+
containerRef = createRef<HTMLDivElement>()
60+
buildContext = vi.fn(() => selection)
61+
root = createRoot(container)
62+
act(() => {
63+
root.render(<Host />)
64+
})
65+
})
66+
67+
afterEach(() => {
68+
act(() => root.unmount())
69+
container.remove()
70+
vi.clearAllMocks()
71+
})
72+
73+
it('attaches the selection when copying from the editor surface', () => {
74+
const written = dispatchCopy('editor-surface')
75+
76+
expect(buildContext).toHaveBeenCalled()
77+
expect(written[SIM_SELECTION_MIME]).toContain('file_selection')
78+
})
79+
80+
it('ignores a copy from a nested input such as the find box', () => {
81+
// The document still holds a highlight, so without the guard the chip would
82+
// ride onto text the user never copied.
83+
const written = dispatchCopy('find-box')
84+
85+
expect(buildContext).not.toHaveBeenCalled()
86+
expect(written[SIM_SELECTION_MIME]).toBeUndefined()
87+
})
88+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { type RefObject, useEffect } from 'react'
4+
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
5+
import type { ChatContext } from '@/stores/panel'
6+
7+
/**
8+
* Rides a selection {@link ChatContext} onto the editor's native copy so a
9+
* highlighted passage copied with Cmd+C pastes into Chat as a reference chip.
10+
*
11+
* Attached in the BUBBLE phase so it runs after the inner editor's own copy
12+
* handler — Monaco and ProseMirror both `clearData()` before writing
13+
* `text/plain`, so the custom type must be added last to survive.
14+
*
15+
* @param buildContext - Returns null when there is no non-empty selection.
16+
* @param enabled - Re-runs the effect for a container that mounts late (behind a
17+
* loading gate); a ref isn't reactive, so the effect would otherwise bail on the
18+
* first render and never re-attach.
19+
*/
20+
export function useSelectionCopyBridge(
21+
containerRef: RefObject<HTMLElement | null>,
22+
buildContext: () => ChatContext | null,
23+
enabled = true
24+
): void {
25+
useEffect(() => {
26+
const dom = containerRef.current
27+
if (!dom || !enabled) return
28+
const onCopy = (e: ClipboardEvent) => {
29+
// A copy from a field nested in the editor — Monaco's find box being the
30+
// common one — bubbles here while the document still holds a highlight,
31+
// so the selection would be attached to text the user never copied.
32+
//
33+
// Only INPUT is skipped, deliberately: Monaco's own editing surface is a
34+
// hidden TEXTAREA, so excluding textareas (as the table grid does, where
35+
// the cell editors really are form fields) would suppress the chip on the
36+
// main copy path this hook exists for.
37+
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return
38+
const context = buildContext()
39+
if (context) attachSelectionContextToClipboard(e.clipboardData, context)
40+
}
41+
dom.addEventListener('copy', onCopy)
42+
return () => dom.removeEventListener('copy', onCopy)
43+
}, [containerRef, buildContext, enabled])
44+
}

0 commit comments

Comments
 (0)