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>
This commit is contained in:
mzxchandra
2026-08-01 16:59:45 -07:00
committed by GitHub
co-authored by Waleed Latif
parent d89ab4a8ee
commit cd50a445fd
40 changed files with 2533 additions and 70 deletions
@@ -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 (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -60,6 +63,15 @@ export function EditorContextMenu({
sideOffset={2}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddToChat && (
<>
<DropdownMenuItem disabled={!hasSelection} onSelect={onAddToChat}>
<Blimp />
Add to Chat
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canEdit && (
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
<Scissors />
@@ -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<HTMLDivElement | null>
/** 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<string | null>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -243,6 +250,17 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
</>
) : (
<>
{onAddToChat && (
<>
<ToolbarButton
icon={Blimp}
label='Add to Chat'
isActive={false}
onClick={onAddToChat}
/>
<ToolbarDivider />
</>
)}
<ToolbarButton
icon={Bold}
label='Bold'
@@ -1,8 +1,9 @@
import type { ComponentType, SVGProps } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import type { LucideIcon } from 'lucide-react'
interface ToolbarButtonProps {
icon: LucideIcon
/** Any SVG icon component — Lucide icons and `@sim/emcn/icons` both satisfy this. */
icon: ComponentType<SVGProps<SVGSVGElement>>
label: string
shortcut?: string
isActive?: boolean
@@ -12,14 +12,21 @@ import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import {
buildFileSelectionLabel,
truncateSelectionText,
} from '@/lib/copilot/chat/selection-context'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
import { useAddToChat } from '@/hooks/use-add-to-chat'
import type { SaveStatus } from '@/hooks/use-autosave'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import type { ChatContext } from '@/stores/panel'
import { PreviewLoadingFrame } from '../preview-shared'
import { useEditableFileContent } from '../use-editable-file-content'
import { useSelectionCopyBridge } from '../use-selection-copy-bridge'
import {
announceAgentApplying,
clearAgentApplying,
@@ -1124,6 +1131,36 @@ export function LoadedRichMarkdownEditor({
[]
)
const addToChat = useAddToChat()
/**
* No line range: this editor renders a ProseMirror document, whose block
* boundaries do not correspond to markdown source lines (blank lines between
* paragraphs, list markers, heading prefixes and fenced blocks all shift the
* real line). Reporting a derived count would label the chip — and prompt the
* agent — with line numbers that don't exist in the file.
*/
const buildSelectionContext = useCallback((): ChatContext | null => {
if (!editor) return null
const { from, to } = editor.state.selection
if (from === to) return null
const text = editor.state.doc.textBetween(from, to, '\n')
if (!text.trim()) return null
return {
kind: 'file_selection',
fileId: file.id,
fileName: file.name,
label: buildFileSelectionLabel(file.name),
text: truncateSelectionText(text),
}
}, [editor, file.id, file.name])
const handleAddSelectionToChat = () => {
const context = buildSelectionContext()
if (context) addToChat(context)
}
useSelectionCopyBridge(containerRef, buildSelectionContext)
// Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet
// seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held
// until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder
@@ -1135,7 +1172,13 @@ export function LoadedRichMarkdownEditor({
ref={containerRef}
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && (
<EditorBubbleMenu
editor={editor}
scrollContainerRef={containerRef}
onAddToChat={handleAddSelectionToChat}
/>
)}
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<input
@@ -5,13 +5,20 @@ import type { OnMount } from '@monaco-editor/react'
import { cn } from '@sim/emcn'
import type { editor as MonacoEditorTypes } from 'monaco-editor'
import dynamic from 'next/dynamic'
import {
buildFileSelectionLabel,
truncateSelectionText,
} from '@/lib/copilot/chat/selection-context'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAddToChat } from '@/hooks/use-add-to-chat'
import type { ChatContext } from '@/stores/panel'
import { EditorContextMenu } from './editor-context-menu'
import type { PreviewMode } from './file-viewer'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import { PreviewLoadingFrame } from './preview-shared'
import { useEditableFileContent } from './use-editable-file-content'
import { useSelectionCopyBridge } from './use-selection-copy-bridge'
const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
@@ -373,6 +380,38 @@ export const TextEditor = memo(function TextEditor({
const monacoLanguage = resolveMonacoLanguage(file)
const monacoTheme = useMonacoTheme()
const addToChat = useAddToChat()
const buildSelectionContext = useCallback((): ChatContext | null => {
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
// A full-line highlight ends at column 1 of the FOLLOWING line, so that line
// contributed no text — reporting it would claim a range one line longer
// than what was selected, in both the chip label and the agent's prompt.
const endLine =
sel.endColumn === 1 && sel.endLineNumber > startLine
? sel.endLineNumber - 1
: sel.endLineNumber
return {
kind: 'file_selection',
fileId: file.id,
fileName: file.name,
label: buildFileSelectionLabel(file.name, startLine, endLine),
text: truncateSelectionText(text),
startLine,
endLine,
}
}, [file.id, file.name])
const handleAddSelectionToChat = () => {
const context = buildSelectionContext()
if (context) addToChat(context)
}
const {
content,
@@ -394,6 +433,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 +693,10 @@ export const TextEditor = memo(function TextEditor({
onClose={closeContextMenu}
hasSelection={contextMenu.hasSelection}
canEdit={!isEditorReadOnly}
onAddToChat={() => {
handleAddSelectionToChat()
closeContextMenu()
}}
onCut={() => {
monacoEditorRef.current?.focus()
monacoEditorRef.current?.trigger(
@@ -0,0 +1,88 @@
/**
* @vitest-environment jsdom
*/
import { act, createRef, type RefObject } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
import type { ChatContext } from '@/stores/panel'
import { useSelectionCopyBridge } from './use-selection-copy-bridge'
const selection: ChatContext = {
kind: 'file_selection',
fileId: 'f1',
fileName: 'notes.md',
label: 'notes.md:2-4',
text: 'the exact passage',
}
let container: HTMLDivElement
let root: Root
let containerRef: RefObject<HTMLDivElement | null>
let buildContext: ReturnType<typeof vi.fn>
/**
* Mirrors the editors this hook wraps: Monaco's editing surface is a hidden
* textarea, and its find widget is a real input nested in the same container.
*/
function Host() {
useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null)
return (
<div ref={containerRef}>
<textarea id='editor-surface' />
<input id='find-box' />
</div>
)
}
/** Dispatches a bubbling copy from `id` and returns what was written. */
function dispatchCopy(id: string): Record<string, string> {
const written: Record<string, string> = {}
const event = new Event('copy', { bubbles: true }) as ClipboardEvent
Object.defineProperty(event, 'clipboardData', {
value: {
setData: (type: string, value: string) => {
written[type] = value
},
},
})
act(() => {
container.querySelector(`#${id}`)?.dispatchEvent(event)
})
return written
}
describe('useSelectionCopyBridge', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
containerRef = createRef<HTMLDivElement>()
buildContext = vi.fn(() => selection)
root = createRoot(container)
act(() => {
root.render(<Host />)
})
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})
it('attaches the selection when copying from the editor surface', () => {
const written = dispatchCopy('editor-surface')
expect(buildContext).toHaveBeenCalled()
expect(written[SIM_SELECTION_MIME]).toContain('file_selection')
})
it('ignores a copy from a nested input such as the find box', () => {
// The document still holds a highlight, so without the guard the chip would
// ride onto text the user never copied.
const written = dispatchCopy('find-box')
expect(buildContext).not.toHaveBeenCalled()
expect(written[SIM_SELECTION_MIME]).toBeUndefined()
})
})
@@ -0,0 +1,44 @@
'use client'
import { type RefObject, useEffect } from 'react'
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
import type { ChatContext } from '@/stores/panel'
/**
* Rides a selection {@link ChatContext} onto the editor's native copy so a
* highlighted passage copied with Cmd+C pastes into Chat as a reference chip.
*
* Attached in the BUBBLE phase so it runs after the inner editor's own copy
* handler — Monaco and ProseMirror both `clearData()` before writing
* `text/plain`, so the custom type must be added last to survive.
*
* @param buildContext - Returns null when there is no non-empty selection.
* @param enabled - Re-runs the effect for a container that mounts late (behind a
* loading gate); a ref isn't reactive, so the effect would otherwise bail on the
* first render and never re-attach.
*/
export function useSelectionCopyBridge(
containerRef: RefObject<HTMLElement | null>,
buildContext: () => ChatContext | null,
enabled = true
): void {
useEffect(() => {
const dom = containerRef.current
if (!dom || !enabled) return
const onCopy = (e: ClipboardEvent) => {
// A copy from a field nested in the editor — Monaco's find box being the
// common one — bubbles here while the document still holds a highlight,
// so the selection would be attached to text the user never copied.
//
// Only INPUT is skipped, deliberately: Monaco's own editing surface is a
// hidden TEXTAREA, so excluding textareas (as the table grid does, where
// the cell editors really are form fields) would suppress the chip on the
// main copy path this hook exists for.
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return
const context = buildContext()
if (context) attachSelectionContextToClipboard(e.clipboardData, context)
}
dom.addEventListener('copy', onCopy)
return () => dom.removeEventListener('copy', onCopy)
}, [containerRef, buildContext, enabled])
}
@@ -75,6 +75,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'Table',
renderIcon: ({ className }) => <TableIcon className={className} />,
},
table_selection: {
label: 'Table selection',
renderIcon: ({ className }) => <TableIcon className={className} />,
},
file: {
label: 'File',
renderIcon: ({ context, className }) => {
@@ -82,6 +86,15 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
return <FileDocIcon className={className} />
},
},
file_selection: {
label: 'File selection',
renderIcon: ({ context, className }) => {
// The label carries a `:line` suffix, so read the extension off the file
// name the context carries — `getDocumentIcon` needs `md`, not `md:12-40`.
const FileDocIcon = getDocumentIcon('', context.fileName ?? context.label)
return <FileDocIcon className={className} />
},
},
folder: {
label: 'Folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
@@ -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)
@@ -64,7 +64,12 @@ interface MothershipChatProps {
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
/**
* Receives the input's context list AFTER the removal, so the owner can tell
* whether another chip still references the removed chip's resource. Matches
* `ChatSurfaceContextValue`, which this forwards to.
*/
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
draftScopeKey?: string
layout?: 'mothership-view' | 'copilot-view'
@@ -160,6 +160,31 @@ export function serializeSelectionForClipboard(
return result
}
/**
* Finds the selection-scoped chips (`file_selection` / `table_selection`) whose
* highlighted token falls inside `selectedText` — the chips the copy/cut path
* must route through the custom clipboard MIME rather than a portable link.
*
* Uses the overlay's exact tokenization so a label that is a substring of
* another never false-matches.
*/
export function selectionContextsInText(
selectedText: string,
contexts: ChatContext[]
): ChatContext[] {
const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts))
if (ranges.length === 0) return []
const found: ChatContext[] = []
for (const range of ranges) {
const label = stripMentionTrigger(range.token)
const matched = contexts.find((c) => c.label === label)
if (matched && (matched.kind === 'file_selection' || matched.kind === 'table_selection')) {
found.push(matched)
}
}
return found
}
/**
* Parses all portable chip markdown links from a string, in source order.
*
@@ -1,9 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
attachSelectionContextToClipboard,
readSelectionContextFromClipboard,
} from '@/lib/copilot/chat/selection-clipboard'
import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'
import {
chipDisplayToken,
chipLinkToContext,
parseChipLinks,
selectionContextsInText,
serializeSelectionForClipboard,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
import {
@@ -20,6 +25,7 @@ import {
useMentionTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
prepareContextForInsert,
restoreSkillTriggerText,
SKILL_CHIP_TRIGGER,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
@@ -461,6 +467,49 @@ export function usePromptEditor({
[textareaRef, addContextNotified]
)
/**
* Inserts contexts as `@label` chips at the caret and registers them. Unlike
* the menu-driven inserts, this is triggered programmatically (the
* highlight-to-chat action in the file/table viewers) rather than by a typed
* `@`/`/` trigger, so it always inserts at the current cursor position.
*
* Takes the whole batch so label collisions resolve against the chips added
* earlier in the same call: `selectedContexts` is React state read through a
* ref, so it does not reflect an add until the next render.
*/
const insertContextChips = useCallback(
(contexts: ChatContext[]) => {
let attached = contextManagementRef.current.selectedContexts
const prepared: ChatContext[] = []
for (const context of contexts) {
const next = prepareContextForInsert(context, attached)
if (!next) continue
prepared.push(next)
attached = [...attached, next]
}
if (prepared.length === 0) {
textareaRef.current?.focus()
return
}
const textarea = textareaRef.current
if (textarea) {
const currentValue = valueRef.current
const insertAt = textarea.selectionStart ?? currentValue.length
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
const insertText = `${needsSpaceBefore ? ' ' : ''}${prepared.map((c) => `@${c.label} `).join('')}`
const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(insertAt)}`
pendingCursorRef.current = insertAt + insertText.length
valueRef.current = newValue
setValueState(newValue)
}
for (const context of prepared) addContextNotified(context)
},
[textareaRef, addContextNotified]
)
/**
* Only reachable via Radix's own dismiss detection (outside click /
* Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass
@@ -876,6 +925,34 @@ export function usePromptEditor({
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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.
//
// `preventDefault` waits until there is a chip to insert: when the selection
// is already attached there is nothing to add, and claiming the event anyway
// would swallow the keystroke entirely — no chip and no text. Falling through
// pastes the selection's plain text, which is what the user asked for.
const selectionContext = readSelectionContextFromClipboard(e.clipboardData)
const preparedSelection = selectionContext
? prepareContextForInsert(selectionContext, contextManagementRef.current.selectedContexts)
: null
if (preparedSelection) {
e.preventDefault()
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 ? ' ' : ''}@${preparedSelection.label} `
textarea.setRangeText(insert, selStart, selEnd, 'end')
const caret = selStart + insert.length
contextManagementRef.current.addContext(preparedSelection)
valueRef.current = textarea.value
setValueState(textarea.value)
requestAnimationFrame(() => textarea.setSelectionRange(caret, caret))
return
}
// Portable chip links (`[label](sim:kind/id)`) re-create their chip on
// paste-back. Rewrite each link span to its `@label ` token (the trailing
// space is REQUIRED so useContextManagement's sync effect doesn't purge the
@@ -967,6 +1044,11 @@ export function usePromptEditor({
* text and round-trip by name. Returns true when it took over the clipboard
* (the caller must then perform the cut deletion itself, since the default
* was prevented).
*
* Selection chips carry an inline text / row-id payload that no portable link
* can hold, so a lone selection chip rides the custom `text/x-sim-selection`
* MIME instead. That slot fits only one, so a mixed selection keeps the
* portable path and its selection chip degrades to bare label text.
*/
const writeSanitizedClipboard = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>): boolean => {
@@ -975,10 +1057,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 +1113,8 @@ export function usePromptEditor({
clear,
focusAtEnd,
insertResources,
/** Inserts contexts as `@label` chips at the caret (highlight-to-chat). */
insertContextChips,
insertSlashTrigger,
openResourceMenu,
/** The editor's textarea element — focus management, caret restore. */
@@ -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<UserInputHandle, UserInputProps>(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<MothershipAddContextDetail>).detail
if (!detail?.contexts?.length) return
e.preventDefault()
editorRef.current.insertContextChips(detail.contexts)
textareaRef.current?.focus()
}
window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler)
return () => window.removeEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handler)
}, [textareaRef])
const isFirstSaveRef = useRef(true)
useEffect(() => {
if (isFirstSaveRef.current) {
@@ -223,7 +245,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(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])
@@ -27,6 +27,7 @@ import {
MothershipHandoffStorage,
} from '@/lib/core/utils/browser-storage'
import {
addMothershipContexts,
MOTHERSHIP_SEND_MESSAGE_EVENT,
type MothershipSendMessageDetail,
} from '@/lib/mothership/events'
@@ -329,19 +330,39 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
}, [sendMessage])
/**
* Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in
* Chat" on an errored log viewed from a different route) and auto-sends it
* into this fresh chat, tagging the run so Sim can inspect the failure. Only
* the cross-route path lands here — when a chat is already mounted the event
* above delivers directly. Gated to the new-chat surface (`!chatId`): a
* Consumes a one-shot handoff left by another surface and applies it to this
* fresh chat. Two shapes arrive here: a message handoff (e.g. "Troubleshoot in
* Chat" on an errored log) is auto-sent with its contexts attached; a
* chip-only handoff (highlight-to-chat from the standalone Files/Tables pages)
* seeds reference chips and sends nothing.
*
* Only the cross-route path lands here — when a chat is already mounted the
* events deliver directly. Gated to the new-chat surface (`!chatId`): a
* handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount
* must never claim it if navigation races. `consume` clears the entry
* atomically, so it fires at most once even across a StrictMode remount.
*
* Chip-only handoffs open each resource directly rather than relying on the
* input's listener being mounted, then dispatch so the input inserts the chip.
* This effect is declared after `useChat`, so its chat-init `setResources([])`
* has already flushed and cannot wipe the just-opened resource.
*/
useEffect(() => {
if (chatId) return
const handoff = MothershipHandoffStorage.consume(workspaceId)
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
if (!handoff) return
if (handoff.message) {
sendMessage(handoff.message, undefined, handoff.contexts)
return
}
const contexts = handoff.contexts ?? []
for (const context of contexts) handleContextAdd(context)
addMothershipContexts(contexts)
// `handleContextAdd` is a body function, so it is a new value every render;
// listing it would re-run this drain on every render. Omitted deliberately to
// keep it one-shot — and harmless either way, since `consume` clears the entry
// atomically and any re-run would find nothing.
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
}, [chatId, workspaceId, sendMessage])
function resolveResourceFromContext(
@@ -355,24 +376,48 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
case 'table':
return context.tableId ? { type: 'table', id: context.tableId } : null
case 'table_selection':
return context.tableId ? { type: 'table', id: context.tableId } : null
case 'file':
return context.fileId ? { type: 'file', id: context.fileId } : null
case 'file_selection':
return context.fileId ? { type: 'file', id: context.fileId } : null
default:
return null
}
}
/**
* Tab title for the resource a chip opens. A selection chip's label describes
* the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the
* whole file/table, so title it from the resource name the context carries.
*/
function resourceTitleForContext(context: ChatContext): string {
if (context.kind === 'file_selection') return context.fileName
if (context.kind === 'table_selection') return context.tableName
return context.label
}
function handleContextAdd(context: ChatContext) {
const resolved = resolveResourceFromContext(context)
if (resolved) {
addResource({ ...resolved, title: context.label })
addResource({ ...resolved, title: resourceTitleForContext(context) })
handleResourceEvent()
}
}
function handleInitialContextRemove(context: ChatContext) {
function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) {
const resolved = resolveResourceFromContext(context)
if (!resolved) return
// A whole-file chip and one or more of its selection chips (or several
// selections of the same file/table) all resolve to the same resource tab.
// Only close the tab once no remaining chip still references it, so removing
// one of several chips doesn't yank a slideover the others still point at.
const stillReferenced = remaining.some((other) => {
const otherResolved = resolveResourceFromContext(other)
return otherResolved?.type === resolved.type && otherResolved.id === resolved.id
})
if (stillReferenced) return
removeResource(resolved.type, resolved.id)
}
@@ -315,8 +315,21 @@ function isChatContext(value: unknown): value is ChatContext {
return value.knowledgeId === undefined || typeof value.knowledgeId === 'string'
case 'table':
return typeof value.tableId === 'string'
case 'table_selection':
return (
typeof value.tableId === 'string' &&
typeof value.tableName === 'string' &&
Array.isArray(value.rowIds) &&
value.rowIds.every((id) => typeof id === 'string')
)
case 'file':
return typeof value.fileId === 'string'
case 'file_selection':
return (
typeof value.fileId === 'string' &&
typeof value.fileName === 'string' &&
typeof value.text === 'string'
)
case 'folder':
return typeof value.folderId === 'string'
case 'filefolder':
@@ -3221,6 +3234,21 @@ export function useChat(
...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}),
...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}),
...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}),
...(c.kind === 'file_selection'
? {
fileName: c.fileName,
text: c.text,
...(c.startLine ? { startLine: c.startLine } : {}),
...(c.endLine ? { endLine: c.endLine } : {}),
}
: {}),
...(c.kind === 'table_selection'
? {
tableName: c.tableName,
rowIds: c.rowIds,
...(c.columnIds ? { columnIds: c.columnIds } : {}),
}
: {}),
}))
const cachedUserMsg: PersistedMessage = {
id: userMessageId,
@@ -148,6 +148,19 @@ export interface ChatMessageContext {
blockType?: string
skillId?: string
serverId?: string
/** Selected passage for a `file_selection` context. */
text?: string
/** Source file name for a `file_selection` context. */
fileName?: string
/** 1-based inclusive line range for a `file_selection` context. */
startLine?: number
endLine?: number
/** Source table name for a `table_selection` context. */
tableName?: string
/** Selected row ids for a `table_selection` context. */
rowIds?: string[]
/** Selected column ids for a `table_selection` cell range. */
columnIds?: string[]
}
export interface ChatMessage {
@@ -8,6 +8,7 @@ import {
import {
ArrowDown,
ArrowUp,
Blimp,
Duplicate,
Eye,
Pencil,
@@ -55,6 +56,21 @@ interface ContextMenuProps {
*/
disableDuplicate?: boolean
disableDelete?: boolean
/** Adds the selected rows / cell range to Chat as a reference. Omit to hide. */
onAddToChat?: () => void
/**
* True when the selection is a spreadsheet-style cell range rather than whole
* rows, switching the label from row-scoped to cell-scoped. Mirrors
* {@link ContextMenuProps.workflowCellScoped}.
*/
addToChatCellScoped?: boolean
/**
* Rows the chip will reference. Differs from {@link ContextMenuProps.selectedRowCount}
* because a gutter selection can extend past the loaded page and the chip
* carries ids the server re-fetches, so the label must not promise fewer rows
* than are actually sent. Defaults to `selectedRowCount`.
*/
addToChatRowCount?: number
}
export function ContextMenu({
@@ -79,6 +95,9 @@ export function ContextMenu({
disableInsert = false,
disableDuplicate = false,
disableDelete = false,
onAddToChat,
addToChatCellScoped = false,
addToChatRowCount,
}: ContextMenuProps) {
const count = selectedRowCount.toLocaleString()
const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row'
@@ -100,6 +119,12 @@ export function ContextMenu({
runningInSelectionCount === 1
? 'Stop running workflow'
: `Stop ${runningInSelectionCount} running workflows`
const addToChatRows = addToChatRowCount ?? selectedRowCount
const addToChatLabel = addToChatCellScoped
? 'Add cell range to Chat'
: addToChatRows > 1
? `Add ${addToChatRows.toLocaleString()} rows to Chat`
: 'Add row to Chat'
return (
<DropdownMenu
@@ -127,6 +152,15 @@ export function ContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddToChat && (
<>
<DropdownMenuItem onSelect={onAddToChat}>
<Blimp />
{addToChatLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{contextMenu.columnName && canEditCell && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEditCell}>
<Pencil />
@@ -11,6 +11,7 @@ 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 { captureEvent } from '@/lib/posthog/client'
import type {
ColumnDefinition,
@@ -41,8 +42,10 @@ import {
useUpdateTableRow,
useUpdateWorkflowGroup,
} from '@/hooks/queries/tables'
import { useAddToChat } from '@/hooks/use-add-to-chat'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo'
import type { ChatContext } from '@/stores/panel'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import { useContextMenu, useTable } from '../../hooks'
import type { EditingCell, QueryOptions, SaveReason } from '../../types'
@@ -61,11 +64,15 @@ import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitiv
import type { DisplayColumn } from './types'
import {
buildHeaderGroups,
buildTableSelectionContext,
type CellCoord,
canWriteRowsWithChip,
checkboxColLayout,
chipRowCount,
classifyExecStatusMix,
collectRowSnapshots,
computeNormalizedSelection,
drainTargetForChip,
type ExecStatusMix,
expandToDisplayColumns,
isCellInSelection,
@@ -77,6 +84,7 @@ import {
rowSelectionIncludes,
rowSelectionIsEmpty,
rowSelectionMaterialize,
selectedColumnIds,
} from './utils'
const logger = createLogger('TableView')
@@ -306,6 +314,40 @@ function cellToText(value: unknown, column?: DisplayColumn): string {
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/**
* Copies `rows` synchronously on the copy event so a chat-selection chip can
* ride alongside the tab-separated text. Eligibility lives in
* {@link canWriteRowsWithChip}; this owns only the clipboard and toast effects.
*
* @returns True when it handled the copy, false to fall through to the paged path.
*/
function writeLoadedRowsWithChip(opts: {
clipboardData: DataTransfer | null
rows: TableRowType[]
complete: boolean
buildCells: (row: TableRowType) => string[]
context: ChatContext | null
}): boolean {
const { rows, context } = opts
if (
!canWriteRowsWithChip({
rowCount: rows.length,
complete: opts.complete,
hasContext: Boolean(context),
}) ||
!context
) {
return false
}
opts.clipboardData?.setData(
'text/plain',
rows.map((row) => opts.buildCells(row).join('\t')).join('\n')
)
attachSelectionContextToClipboard(opts.clipboardData, context)
toast.success(`Copied ${rows.length} ${rows.length === 1 ? 'row' : 'rows'}`)
return true
}
/**
* Value-equality for a cell's stored value vs a pending edit. Primitives compare
* with `===`; arrays/objects (multiselect id arrays, json) compare structurally
@@ -992,6 +1034,9 @@ export function TableGrid({
const rowSelectionRef = useRef(rowSelection)
rowSelectionRef.current = rowSelection
const tableNameRef = useRef(tableData?.name)
tableNameRef.current = tableData?.name
columnsRef.current = displayColumns
schemaColumnsRef.current = columns
workflowGroupsRef.current = tableWorkflowGroups
@@ -2925,6 +2970,30 @@ export function TableGrid({
if (!rowSelectionIsEmpty(rowSel)) {
e.preventDefault()
// Only an explicit multi-row selection can take this path: a filtered
// select-all ('all') pages in rows beyond those loaded, which the async
// fall-through must fetch. `complete` refers to the copied TEXT only —
// for 'some' the fall-through re-reads these same loaded rows (see its
// `loadRows`), so it can never serialize more than this does. The chip
// is not bound by that; see `rowIds` below.
if (rowSel.kind === 'some') {
const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id))
const handled = writeLoadedRowsWithChip({
clipboardData: e.clipboardData,
rows: selectedRows,
complete: true,
buildCells: (row) => cols.map((col) => cellToText(row.data[col.key], col)),
context: buildTableSelectionContext({
tableId,
tableName: tableNameRef.current,
// Every selected id, not just the loaded page: the chip carries
// ids and the server re-fetches them, so an unloaded row still
// reaches the agent. Only the pasted text is limited to `rows`.
rowIds: [...rowSel.ids],
}),
})
if (handled) return
}
writeSelectionToClipboard({
loadRows:
rowSel.kind === 'all'
@@ -2953,6 +3022,24 @@ export function TableGrid({
if (name) colNames.push(name)
}
const colByKey = new Map(cols.map((c) => [c.key, c]))
// A column-header selection spans every row, and its fall-through pages
// in the rest — so the chip path applies only once all of them are here.
const handled = writeLoadedRowsWithChip({
clipboardData: e.clipboardData,
rows: currentRows,
complete: currentRows.length >= selectAllTotalRef.current,
buildCells: (row) =>
colNames.map((name) => cellToText(row.data[name], colByKey.get(name))),
context: buildTableSelectionContext({
tableId,
tableName: tableNameRef.current,
rowIds: currentRows.map((row) => row.id),
columnIds: selectedColumnIds(cols, sel),
}),
})
if (handled) return
writeSelectionToClipboard({
loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS),
selectRow: () => true,
@@ -2964,6 +3051,21 @@ export function TableGrid({
return
}
// The cell-range write below is already synchronous, so the chip simply
// rides along on the same event.
const rangeRowIds: string[] = []
for (let r = sel.startRow; r <= sel.endRow; r++) {
const row = currentRows[r]
if (row) rangeRowIds.push(row.id)
}
const rangeContext = buildTableSelectionContext({
tableId,
tableName: tableNameRef.current,
rowIds: rangeRowIds,
columnIds: selectedColumnIds(cols, sel),
})
if (rangeContext) attachSelectionContextToClipboard(e.clipboardData, rangeContext)
const lines: string[] = []
for (let r = sel.startRow; r <= sel.endRow; r++) {
const cells: string[] = []
@@ -3682,6 +3784,95 @@ export function TableGrid({
)
: contextMenuRowIds.length || 1
/**
* Rows the Add to Chat chip will reference, before the async drain that
* select-all and column selections perform. A gutter `some` selection can
* extend past the loaded page, and the chip carries ids the server re-fetches,
* so it uses the whole set rather than the loaded intersection
* `contextMenuRowIds` holds. Shared with the menu label so the count shown and
* the count sent can't disagree.
*/
const addToChatRowIds = useMemo<string[]>(() => {
if (
rowSelection.kind === 'some' &&
contextMenu.row &&
rowSelectionIncludes(rowSelection, contextMenu.row.id)
) {
return [...rowSelection.ids]
}
return contextMenuRowIds
}, [rowSelection, contextMenu.row, contextMenuRowIds])
/**
* 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).
*/
const contextMenuColumnIds = useMemo<string[] | undefined>(() => {
if (!contextMenu.isOpen || !contextMenu.row) return undefined
if (
!rowSelectionIsEmpty(rowSelection) &&
rowSelectionIncludes(rowSelection, contextMenu.row.id)
) {
return undefined
}
const sel = normalizedSelection
if (!sel) return undefined
const contextRowArrayIndex = rows.findIndex((r) => r.id === contextMenu.row!.id)
if (contextRowArrayIndex < sel.startRow || contextRowArrayIndex > sel.endRow) return undefined
// Collapsed here too (not only in buildTableSelectionContext) because this
// also decides whether the menu item reads "cell range" or "rows".
// Not collapsed to `undefined` when it spans every visible column: hidden
// columns mean "all visible" is not "all", and widening would send the agent
// columns the user hid. See buildTableSelectionContext.
const ids = selectedColumnIds(displayColumns, sel)
return ids.length > 0 ? 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 = addToChatRowIds
if (contextMenuIsSelectAll || isColumnSelectionRef.current) {
try {
const excludedCount =
rowSelectionRef.current.kind === 'all' ? (rowSelectionRef.current.excluded?.size ?? 0) : 0
const { rows: loaded } = await ensureRowsLoadedUpToRef.current(
drainTargetForChip(excludedCount)
)
// A column selection spans all rows; a gutter select-all filters by the
// (exclusion-aware) row selection.
const drained = (
contextMenuIsSelectAll
? loaded.filter((row) => rowSelectionIncludes(rowSelectionRef.current, row.id))
: loaded
).map((row) => row.id)
if (drained.length > 0) sourceRowIds = drained
} catch {
// Fall back to the already-loaded rows if the drain fails.
}
}
const context = buildTableSelectionContext({
tableId,
tableName: tableData?.name,
rowIds: sourceRowIds,
columnIds: contextMenuColumnIds,
})
if (context) addToChat(context)
}, [
addToChat,
addToChatRowIds,
contextMenuColumnIds,
contextMenuIsSelectAll,
tableId,
tableData?.name,
])
const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null
/**
@@ -4404,6 +4595,11 @@ export function TableGrid({
disableInsert={!canManualAddRow}
disableDuplicate={!canInsertFullRow}
disableDelete={!canDeleteRow}
onAddToChat={addToChatRowIds.length > 0 ? handleAddSelectionToChat : undefined}
addToChatCellScoped={Boolean(contextMenuColumnIds)}
addToChatRowCount={chipRowCount(
contextMenuIsSelectAll ? selectedRowCount : addToChatRowIds.length
)}
/>
<ExpandedCellPopover
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
MAX_TABLE_SELECTION_COLUMNS,
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/copilot/chat/selection-context'
import { TABLE_LIMITS } from '@/lib/table/constants'
import type { DisplayColumn } from './types'
import {
buildTableSelectionContext,
canWriteRowsWithChip,
chipRowCount,
drainTargetForChip,
selectedColumnIds,
} from './utils'
function columns(count: number): DisplayColumn[] {
return Array.from({ length: count }, (_, i) => ({
id: `c${i}`,
name: `Col ${i}`,
})) as unknown as DisplayColumn[]
}
const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`)
describe('selectedColumnIds', () => {
it('returns the ids the range spans', () => {
expect(selectedColumnIds(columns(5), { startCol: 1, endCol: 3 })).toEqual(['c1', 'c2', 'c3'])
})
it('stops at the last column when the range overruns', () => {
expect(selectedColumnIds(columns(2), { startCol: 0, endCol: 9 })).toEqual(['c0', 'c1'])
})
})
describe('buildTableSelectionContext', () => {
const base = { tableId: 't1', tableName: 'Sales' }
it('returns null before the table name has loaded, or with nothing selected', () => {
expect(buildTableSelectionContext({ ...base, tableName: undefined, rowIds: ['r1'] })).toBeNull()
expect(buildTableSelectionContext({ ...base, rowIds: [] })).toBeNull()
})
it('caps rows at the chip limit and labels the capped count, not the requested one', () => {
const context = buildTableSelectionContext({
...base,
rowIds: rowIds(MAX_TABLE_SELECTION_ROWS + 250),
})
expect(context?.kind).toBe('table_selection')
if (context?.kind !== 'table_selection') throw new Error('expected a table_selection')
expect(context.rowIds).toHaveLength(MAX_TABLE_SELECTION_ROWS)
expect(context.label).toContain(`${MAX_TABLE_SELECTION_ROWS} rows`)
})
it('keeps a full-width range scoped rather than widening it to every column', () => {
// Callers can only count rendered columns, which drop hidden ones and expand
// workflow groups — so "covers everything visible" is not "covers the
// schema". Widening here would re-fetch columns the user had hidden.
const context = buildTableSelectionContext({
...base,
rowIds: ['r1'],
columnIds: ['c0', 'c1', 'c2'],
})
if (context?.kind !== 'table_selection') throw new Error('expected a table_selection')
expect(context.columnIds).toEqual(['c0', 'c1', 'c2'])
})
it('leaves the scope open only when no columns are given (whole rows)', () => {
const context = buildTableSelectionContext({ ...base, rowIds: ['r1'] })
if (context?.kind !== 'table_selection') throw new Error('expected a table_selection')
expect(context.columnIds).toBeUndefined()
})
it('keeps a narrower range scoped, capped at the column limit', () => {
const context = buildTableSelectionContext({
...base,
rowIds: ['r1'],
columnIds: Array.from({ length: MAX_TABLE_SELECTION_COLUMNS + 10 }, (_, i) => `c${i}`),
})
if (context?.kind !== 'table_selection') throw new Error('expected a table_selection')
expect(context.columnIds).toHaveLength(MAX_TABLE_SELECTION_COLUMNS)
})
})
describe('canWriteRowsWithChip', () => {
const ok = { rowCount: 10, complete: true, hasContext: true }
it('allows a complete, in-bounds selection that has a chip to carry', () => {
expect(canWriteRowsWithChip(ok)).toBe(true)
})
it('defers when there is no chip, nothing selected, or the paged path would load more', () => {
expect(canWriteRowsWithChip({ ...ok, hasContext: false })).toBe(false)
expect(canWriteRowsWithChip({ ...ok, rowCount: 0 })).toBe(false)
expect(canWriteRowsWithChip({ ...ok, complete: false })).toBe(false)
})
it('stays allowed past the chip row cap — the context caps itself', () => {
// Gating on MAX_TABLE_SELECTION_ROWS here would drop the chip entirely on
// the async fall-through, while Add to Chat on the same selection still
// produces a capped chip.
expect(canWriteRowsWithChip({ ...ok, rowCount: MAX_TABLE_SELECTION_ROWS + 100 })).toBe(true)
})
it('defers past the text copy limit, which owns truncation', () => {
expect(canWriteRowsWithChip({ ...ok, rowCount: TABLE_LIMITS.MAX_COPY_ROWS })).toBe(true)
expect(canWriteRowsWithChip({ ...ok, rowCount: TABLE_LIMITS.MAX_COPY_ROWS + 1 })).toBe(false)
})
})
describe('chipRowCount', () => {
it.each([1, 42, MAX_TABLE_SELECTION_ROWS, MAX_TABLE_SELECTION_ROWS + 250, 50_000])(
'agrees with what a context carries for %i requested rows',
(requested) => {
// The invariant that keeps breaking: a label derived from the raw
// selection size over-promises once the context caps its rowIds, and one
// derived from the loaded page under-promises. Both must equal this.
const context = buildTableSelectionContext({
tableId: 't1',
tableName: 'Sales',
rowIds: rowIds(requested),
})
if (context?.kind !== 'table_selection') throw new Error('expected a table_selection')
expect(chipRowCount(requested)).toBe(context.rowIds.length)
}
)
})
describe('drainTargetForChip', () => {
it('still yields a full cap when every exclusion lands in the loaded prefix', () => {
// The worst case for a gutter select-all: exclusions are filtered out AFTER
// loading, so loading only the cap would leave the chip short of the count
// the menu already advertised.
const excluded = 30
expect(drainTargetForChip(excluded) - excluded).toBe(MAX_TABLE_SELECTION_ROWS)
})
it('loads exactly the cap when nothing is excluded', () => {
expect(drainTargetForChip(0)).toBe(MAX_TABLE_SELECTION_ROWS)
})
})
@@ -1,4 +1,9 @@
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import {
buildTableSelectionLabel,
MAX_TABLE_SELECTION_COLUMNS,
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/copilot/chat/selection-context'
import type {
ColumnDefinition,
RowExecutionMetadata,
@@ -7,7 +12,9 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
import type { ChatContext } from '@/stores/panel'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import type { DisplayColumn } from './types'
@@ -351,3 +358,94 @@ export function collectRowSnapshots(rows: Iterable<TableRowType>): DeletedRowSna
}
return snapshots
}
/** Column ids spanned by a normalized selection's column range. */
export function selectedColumnIds(
columns: DisplayColumn[],
selection: { startCol: number; endCol: number }
): string[] {
const ids: string[] = []
for (let c = selection.startCol; c <= selection.endCol && c < columns.length; c++) {
ids.push(getColumnId(columns[c]))
}
return ids
}
/**
* Materializes a `table_selection` chat context from a grid selection, applying
* the shared row/column caps. `columnIds` narrows the context to a cell range;
* omit it for a whole-row selection, where the agent should see every column.
* Returns null before the table name has loaded or when nothing is selected.
*
* A range is never widened back to an open scope for "covering everything":
* the only counts available to callers come from the rendered grid, which both
* drops hidden columns and expands workflow groups, so "all of them" cannot be
* compared to the schema. Treating a full-width range as whole rows would let
* the server re-fetch columns the user had hidden.
*/
export function buildTableSelectionContext(opts: {
tableId: string
tableName: string | undefined
rowIds: string[]
columnIds?: string[]
}): ChatContext | null {
const { tableId, tableName, columnIds } = opts
if (!tableName || opts.rowIds.length === 0) return null
const rowIds = opts.rowIds.slice(0, MAX_TABLE_SELECTION_ROWS)
const scopedColumnIds =
columnIds && columnIds.length > 0 ? columnIds.slice(0, MAX_TABLE_SELECTION_COLUMNS) : undefined
return {
kind: 'table_selection',
tableId,
tableName,
label: buildTableSelectionLabel(tableName, rowIds.length, scopedColumnIds?.length),
rowIds,
...(scopedColumnIds ? { columnIds: scopedColumnIds } : {}),
}
}
/**
* How many rows to load before building a select-all chip. A gutter select-all
* can carry exclusions anywhere in the table, and they are filtered out AFTER
* loading — so loading only {@link MAX_TABLE_SELECTION_ROWS} yields fewer than
* the cap whenever an excluded row sits in that prefix, leaving the chip short
* of the count the menu advertised. Loading the cap plus the exclusion count
* covers the worst case, where every exclusion falls inside the prefix.
*/
export function drainTargetForChip(excludedCount: number): number {
return MAX_TABLE_SELECTION_ROWS + excludedCount
}
/**
* Rows a chip will actually reference for a selection of `requested` rows —
* {@link buildTableSelectionContext} caps its `rowIds`, so any count shown to
* the user must pass through here or the UI promises more than it sends.
*/
export function chipRowCount(requested: number): number {
return Math.min(requested, MAX_TABLE_SELECTION_ROWS)
}
/**
* Whether a copy can be written synchronously on the event — the only way a
* chat-selection chip survives, since the paged path's async Clipboard API write
* replaces the whole clipboard and cannot carry a custom MIME type.
*
* Bounded by the TEXT limit, not the chip's row cap: a context slices its own
* `rowIds` to {@link MAX_TABLE_SELECTION_ROWS}, so a larger selection should
* still copy in full here and carry a chip for as many rows as a chip can
* reference — matching what Add to Chat does with the same selection. Gating on
* the chip cap instead drops the chip entirely. Past `MAX_COPY_ROWS` the paged
* path must take over, because it owns truncation and its user-facing notice.
*
* @param complete - Whether the caller's rows are everything the copy should
* contain. False when the paged path would load rows the caller cannot see yet,
* so deferring to it copies strictly more.
*/
export function canWriteRowsWithChip(opts: {
rowCount: number
complete: boolean
hasContext: boolean
}): boolean {
if (!opts.hasContext || !opts.complete) return false
return opts.rowCount > 0 && opts.rowCount <= TABLE_LIMITS.MAX_COPY_ROWS
}
@@ -0,0 +1,100 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
import type { ChatContext } from '@/stores/panel'
let container: HTMLDivElement
let root: Root
let latest: ReturnType<typeof useContextManagement>
/** Renders the hook with a fixed message and initial contexts, exposing its result. */
function renderSync(message: string, initialContexts: ChatContext[]) {
function Host() {
latest = useContextManagement({ message, initialContexts })
return null
}
act(() => {
root.render(<Host />)
})
}
const fileSelection = (label: string): ChatContext => ({
kind: 'file_selection',
fileId: 'f1',
fileName: 'notes.md',
label,
text: 'passage',
})
const tableSelection = (label: string, rowIds: string[]): ChatContext => ({
kind: 'table_selection',
tableId: 't1',
tableName: 'Sales',
label,
rowIds,
})
describe('useContextManagement label sync', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('drops a chip whose label is only a prefix of a surviving one', () => {
// `@notes.md:12` matches inside `@notes.md:12-40` — the token lookahead
// rejects word characters but not `-`, so only the longer chip is really
// present and the shorter must not linger and get sent.
renderSync('look at @notes.md:12-40 please', [
fileSelection('notes.md:12'),
fileSelection('notes.md:12-40'),
])
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12-40'])
})
it('drops an un-ordinalized chip when only its ordinal twin remains', () => {
renderSync('see @Sales (3 rows) (2) here', [
tableSelection('Sales (3 rows)', ['r1', 'r2', 'r3']),
tableSelection('Sales (3 rows) (2)', ['r7', 'r8', 'r9']),
])
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['Sales (3 rows) (2)'])
})
it('keeps both when both tokens are present', () => {
renderSync('@notes.md:12 and @notes.md:12-40', [
fileSelection('notes.md:12'),
fileSelection('notes.md:12-40'),
])
expect(latest.selectedContexts.map((c) => c.label).sort()).toEqual([
'notes.md:12',
'notes.md:12-40',
])
})
it('preserves the original context order, not the length-sorted one', () => {
renderSync('@notes.md:12 and @notes.md:12-40', [
fileSelection('notes.md:12'),
fileSelection('notes.md:12-40'),
])
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12', 'notes.md:12-40'])
})
it('still tolerates trailing punctuation after a mention', () => {
renderSync('ask @notes.md:12-40, then stop', [fileSelection('notes.md:12-40')])
expect(latest.selectedContexts).toHaveLength(1)
})
})
@@ -68,9 +68,20 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
setSelectedContexts((prev) => {
if (prev.length === 0) return prev
const filtered = prev.filter((c) => {
if (!c.label) return false
// Check for slash command tokens or mention tokens based on kind.
// Longest label first, masking each token once it matches. One label can be
// a prefix of another — `notes.md:12` of `notes.md:12-40`, `Sales (3 rows)`
// of `Sales (3 rows) (2)` — and the lookahead below only rejects a
// following word character, so `-`, `)` and space all let the shorter
// pattern match INSIDE the longer token. Without masking, deleting the
// shorter chip would leave its context attached and still send it.
const byLabelLengthDesc = [...prev].sort(
(a, b) => (b.label?.length ?? 0) - (a.label?.length ?? 0)
)
let unclaimed = message
const present = new Set<ChatContext>()
for (const c of byLabelLengthDesc) {
if (!c.label) continue
// The trailing lookahead `(?![A-Za-z0-9_])` accepts any word-boundary
// — whitespace, end-of-string, or punctuation — so `@Slack.` and
// `@Slack,` survive the sync. A strict `(\s|$)` here would strip
@@ -89,8 +100,19 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])`
)
return tokenPattern.test(message)
})
const match = tokenPattern.exec(unclaimed)
if (!match) continue
present.add(c)
// Blank the claimed span (same length, so later indices stay valid)
// rather than removing it, keeping the `(^|\s)` boundary intact for
// whatever sits next to it.
unclaimed =
unclaimed.slice(0, match.index) +
' '.repeat(match[0].length) +
unclaimed.slice(match.index + match[0].length)
}
const filtered = prev.filter((c) => present.has(c))
return filtered.length === prev.length ? prev : filtered
})
}, [message])
@@ -0,0 +1,123 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
prepareContextForInsert,
uniqueContextLabel,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
function fileSelection(overrides: Partial<Extract<ChatContext, { kind: 'file_selection' }>> = {}) {
return {
kind: 'file_selection',
fileId: 'file-1',
fileName: 'notes.md',
label: 'notes.md:12-40',
text: 'the exact passage',
...overrides,
} as ChatContext
}
function tableSelection(
overrides: Partial<Extract<ChatContext, { kind: 'table_selection' }>> = {}
) {
return {
kind: 'table_selection',
tableId: 'tbl-1',
tableName: 'Sales',
label: 'Sales (3 rows)',
rowIds: ['r1', 'r2', 'r3'],
...overrides,
} as ChatContext
}
describe('uniqueContextLabel', () => {
it('returns the label untouched when it is free', () => {
expect(uniqueContextLabel('Sales (3 rows)', [])).toBe('Sales (3 rows)')
})
it('appends an ordinal on collision, and keeps counting', () => {
const taken = [tableSelection(), tableSelection({ label: 'Sales (3 rows) (2)' })]
expect(uniqueContextLabel('Sales (3 rows)', taken)).toBe('Sales (3 rows) (3)')
})
})
describe('prepareContextForInsert', () => {
it('rejects re-adding the exact same selection', () => {
expect(prepareContextForInsert(tableSelection(), [tableSelection()])).toBeNull()
expect(prepareContextForInsert(fileSelection(), [fileSelection()])).toBeNull()
})
it('relabels a different selection that describes itself the same way', () => {
// Two distinct 3-row picks from one table both read "Sales (3 rows)". Chips
// are keyed by their @label, so the second must be relabeled, not dropped.
const other = tableSelection({ rowIds: ['r7', 'r8', 'r9'] })
expect(prepareContextForInsert(other, [tableSelection()])).toEqual({
...other,
label: 'Sales (3 rows) (2)',
})
})
it('treats a different passage of the same file as a new selection', () => {
const other = fileSelection({ text: 'a different passage' })
expect(prepareContextForInsert(other, [fileSelection()])).not.toBeNull()
})
it('distinguishes identical text highlighted at two places in one file', () => {
// A repeated line — an import, a closing brace — selected twice. Comparing
// text alone would call the second a duplicate and silently drop its chip.
const first = fileSelection({ label: 'notes.md:12', startLine: 12, endLine: 12 })
const second = fileSelection({ label: 'notes.md:50', startLine: 50, endLine: 50 })
expect(prepareContextForInsert(second, [first])).not.toBeNull()
expect(prepareContextForInsert(first, [first])).toBeNull()
})
it('still dedupes identical text when the source has no line numbers', () => {
// The rich-markdown editor omits the range, so two identical passages are
// indistinguishable in the data model and deduping is the honest outcome.
expect(prepareContextForInsert(fileSelection(), [fileSelection()])).toBeNull()
})
it('treats the same rows picked in a different order as one selection', () => {
// Row ids iterate in click order, so re-picking the same rows differently
// must no-op rather than add a second chip over rows already referenced.
const reordered = tableSelection({ rowIds: ['r3', 'r1', 'r2'] })
expect(prepareContextForInsert(reordered, [tableSelection()])).toBeNull()
})
it('distinguishes a cell range from the whole rows it spans', () => {
const range = tableSelection({ columnIds: ['c_name'] })
expect(prepareContextForInsert(range, [tableSelection()])).not.toBeNull()
})
it('passes an uncontested context through unchanged', () => {
const context = tableSelection()
expect(prepareContextForInsert(context, [])).toEqual(context)
})
it('ordinalizes within a batch when the caller threads each result forward', () => {
// How insertContextChips applies a multi-context handoff: `selectedContexts`
// is React state read through a ref and does not reflect an add until the
// next render, so the batch must accumulate locally or the second colliding
// chip is silently dropped.
const batch = [tableSelection(), tableSelection({ rowIds: ['r7', 'r8', 'r9'] })]
let attached: ChatContext[] = []
const prepared: ChatContext[] = []
for (const context of batch) {
const next = prepareContextForInsert(context, attached)
if (!next) continue
prepared.push(next)
attached = [...attached, next]
}
expect(prepared.map((c) => c.label)).toEqual(['Sales (3 rows)', 'Sales (3 rows) (2)'])
})
})
@@ -190,6 +190,24 @@ type IntegrationContext = Extract<ChatContext, { kind: 'integration' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
type SkillContext = Extract<ChatContext, { kind: 'skill' }>
type McpContext = Extract<ChatContext, { kind: 'mcp' }>
type FileSelectionContext = Extract<ChatContext, { kind: 'file_selection' }>
type TableSelectionContext = Extract<ChatContext, { kind: 'table_selection' }>
/**
* Set equality for two optional id lists.
*
* Deliberately order-insensitive: a table selection's row ids come from a Set
* whose iteration order follows click order, and the same rows picked in a
* different order — or via a cell range rather than the gutter — are the same
* selection. Comparing by index would call those distinct and add a duplicate
* ordinalized chip pointing at rows already referenced.
*/
function sameIds(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) return true
if (!a || !b || a.length !== b.length) return false
const inA = new Set(a)
return b.every((id) => inA.has(id))
}
/**
* Checks if two contexts of the same kind are equal by their ID fields.
@@ -231,6 +249,32 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean
const ctx = context as FileContext
return c.fileId === ctx.fileId
}
// Selection kinds scope to part of a resource, so equality is the selected
// range — not the file/table — or re-selecting a different passage of an
// already-referenced file would be swallowed as a duplicate.
case 'file_selection': {
const ctx = context as FileSelectionContext
// Location too, not just the text: the same line can occur twice in a file
// (a repeated import, a closing brace), and comparing text alone would
// treat the second highlight as a duplicate and drop its chip. Where the
// source has no line numbers — the rich-markdown editor — both are
// undefined and identical text is genuinely indistinguishable, so it
// correctly still dedupes.
return (
c.fileId === ctx.fileId &&
c.text === ctx.text &&
c.startLine === ctx.startLine &&
c.endLine === ctx.endLine
)
}
case 'table_selection': {
const ctx = context as TableSelectionContext
return (
c.tableId === ctx.tableId &&
sameIds(c.rowIds, ctx.rowIds) &&
sameIds(c.columnIds, ctx.columnIds)
)
}
case 'logs': {
const ctx = context as LogsContext
return c.executionId === ctx.executionId
@@ -299,3 +343,43 @@ export function isContextAlreadySelected(
return areContextsEqual(c, context)
})
}
/**
* Returns `label`, or the first free `label (n)` variant when it is already
* taken. Two genuinely different selections can legitimately describe
* themselves the same way — two 3-row picks from one table both read
* `Sales (3 rows)` — but the token system keys chips by their `@label`, so a
* collision would silently drop the second context. The ordinal keeps both
* chips alive and stays readable in the input, unlike an opaque hash.
*
* Only meaningful for programmatically inserted contexts; menu-driven picks
* name a distinct resource and dedupe correctly via
* {@link isContextAlreadySelected}.
*/
export function uniqueContextLabel(label: string, selectedContexts: ChatContext[]): string {
const taken = new Set(selectedContexts.map((c) => c.label))
if (!taken.has(label)) return label
for (let n = 2; ; n++) {
const candidate = `${label} (${n})`
if (!taken.has(candidate)) return candidate
}
}
/**
* Insert policy for a context pushed into the input programmatically — the
* highlight-to-chat action and the selection paste, neither of which goes
* through a typed `@`/`/` trigger.
*
* @returns `null` when the exact context is already attached (re-adding the same
* selection is a no-op), otherwise the context carrying a collision-free label.
*/
export function prepareContextForInsert(
context: ChatContext,
selectedContexts: ChatContext[]
): ChatContext | null {
const isDuplicate = selectedContexts.some(
(c) => c.kind === context.kind && areContextsEqual(c, context)
)
if (isDuplicate) return null
return { ...context, label: uniqueContextLabel(context.label, selectedContexts) }
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import { addMothershipContexts } from '@/lib/mothership/events'
import type { ChatContext } from '@/stores/panel'
/**
* Returns a callback that attaches a context chip to the Sim Agent (Chat) input
* without sending — the "add to chat" side of the highlight-to-chat flow. When a
* chat input is mounted (e.g. the Chat surface alongside the file/table viewer)
* the chip is inserted live and the source resource opens in the slideover.
* Otherwise the context is persisted as a chip-only handoff and we navigate to
* Chat, where it seeds the input and opens the resource on mount.
*
* Navigation is gated on a successful store, so a failed write never strands the
* user on an empty chat.
*/
export function useAddToChat(): (context: ChatContext) => void {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
return useCallback(
(context: ChatContext) => {
if (addMothershipContexts([context])) return
if (!workspaceId) return
if (MothershipHandoffStorage.store({ contexts: [context] }, workspaceId)) {
router.push(`/workspace/${workspaceId}/home`)
}
},
[workspaceId, router]
)
}
@@ -130,6 +130,8 @@ function toDisplayContexts(
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
}))
}
@@ -235,6 +235,47 @@ describe('persisted-message', () => {
expect(msg.fileAttachments).toBeUndefined()
expect(msg.contexts).toBeUndefined()
})
it('persists the source names a selection chip renders from, but not its payload', () => {
const msg = buildPersistedUserMessage({
id: 'user-1',
content: 'explain this',
contexts: [
{
kind: 'file_selection',
label: 'notes.md:12-40',
fileId: 'f1',
fileName: 'notes.md',
// Send-time payload: resolved server-side, never re-read for display.
text: 'the exact passage',
startLine: 12,
endLine: 40,
},
{
kind: 'table_selection',
label: 'Sales (2 rows)',
tableId: 't1',
tableName: 'Sales',
rowIds: ['r1', 'r2'],
},
],
})
// fileName must survive: the label carries a `:12-40` suffix, so the chip's
// icon cannot recover an extension from it after a reload.
expect(msg.contexts?.[0]).toEqual({
kind: 'file_selection',
label: 'notes.md:12-40',
fileId: 'f1',
fileName: 'notes.md',
})
expect(msg.contexts?.[1]).toEqual({
kind: 'table_selection',
label: 'Sales (2 rows)',
tableId: 't1',
tableName: 'Sales',
})
})
})
describe('stripToolResultOutput', () => {
@@ -78,6 +78,18 @@ interface PersistedMessageContext {
blockType?: string
skillId?: string
serverId?: string
/**
* Source names for `file_selection` / `table_selection` chips. Persisted
* because the rendered chip reads them — the label carries a location suffix
* (`notes.md:12-40`), so the file icon cannot derive an extension from it.
*
* The rest of a selection's payload (`text`, `rowIds`, `columnIds`, line
* numbers) is deliberately NOT persisted: it exists to resolve the selection
* server-side when the message is sent, is never read when re-rendering a past
* message, and would put a selection-sized blob in every stored message.
*/
fileName?: string
tableName?: string
}
export interface PersistedMessage {
@@ -359,6 +371,8 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
}))
}
@@ -680,6 +694,8 @@ export function normalizeMessage(raw: Record<string, unknown>): PersistedMessage
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
}))
}
+14
View File
@@ -22,6 +22,11 @@ import {
processContextsServer,
resolveActiveResourceContext,
} from '@/lib/copilot/chat/process-contents'
import {
MAX_FILE_SELECTION_TEXT_LENGTH,
MAX_TABLE_SELECTION_COLUMNS,
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/copilot/chat/selection-context'
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
import { chatPubSub } from '@/lib/copilot/chat-status'
@@ -145,7 +150,9 @@ const ChatContextSchema = z.object({
'knowledge',
'docs',
'table',
'table_selection',
'file',
'file_selection',
'folder',
'filefolder',
'scheduledtask',
@@ -171,6 +178,13 @@ const ChatContextSchema = z.object({
scheduleId: z.string().optional(),
tabId: z.string().optional(),
terminalId: z.string().optional(),
text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(),
fileName: z.string().optional(),
startLine: z.number().int().positive().optional(),
endLine: z.number().int().positive().optional(),
tableName: z.string().optional(),
rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(),
columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(),
})
const ChatMessageSchema = z.object({
@@ -4,15 +4,26 @@
import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_TABLE_SELECTION_CONTENT_LENGTH,
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/copilot/chat/selection-context'
import type { ChatContext } from '@/stores/panel'
const { discoverServerTools, getSkillById } = vi.hoisted(() => ({
discoverServerTools: vi.fn(),
getSkillById: vi.fn(),
}))
const { discoverServerTools, getSkillById, getWorkspaceFile, getTableById, getRowsByIds } =
vi.hoisted(() => ({
discoverServerTools: vi.fn(),
getSkillById: vi.fn(),
getWorkspaceFile: vi.fn(),
getTableById: vi.fn(),
getRowsByIds: vi.fn(),
}))
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } }))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile }))
vi.mock('@/lib/table/service', () => ({ getTableById }))
vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds }))
/**
* Overrides the global `@sim/db` mock: the logs-context tests below need
@@ -294,3 +305,333 @@ describe('processContextsServer - logs contexts', () => {
expect(result).toEqual([])
})
})
describe('processContextsServer - file_selection contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('inlines the selected passage with its line range and a path pointer', async () => {
getWorkspaceFile.mockResolvedValue({ name: 'notes.md', folderPath: null })
const result = await processContextsServer(
[
{
kind: 'file_selection',
fileId: 'file-1',
label: 'notes.md:12-14',
text: 'the exact passage',
startLine: 12,
endLine: 14,
} as ChatContext,
],
'user-1',
'explain this',
'ws-1'
)
expect(getWorkspaceFile).toHaveBeenCalledWith('ws-1', 'file-1')
expect(result).toHaveLength(1)
const [ctx] = result
expect(ctx.type).toBe('file_selection')
expect(ctx.tag).toBe('@notes.md:12-14')
expect(ctx.content).toContain('lines 12-14')
expect(ctx.content).toContain('the exact passage')
expect(ctx.path).toBeTruthy()
})
it('drops the selection when the file does not resolve', async () => {
getWorkspaceFile.mockResolvedValue(null)
const result = await processContextsServer(
[
{
kind: 'file_selection',
fileId: 'missing',
label: 'x',
text: 'anything',
} as ChatContext,
],
'user-1',
'hello',
'ws-1'
)
expect(result).toEqual([])
})
it('widens the code fence so an embedded ``` block cannot close it early', async () => {
getWorkspaceFile.mockResolvedValue({ name: 'readme.md', folderPath: null })
const snippet = 'before\n```ts\nconst x = 1\n```\nafter'
const result = await processContextsServer(
[
{
kind: 'file_selection',
fileId: 'file-1',
label: 'readme.md:1-5',
text: snippet,
startLine: 1,
endLine: 5,
} as ChatContext,
],
'user-1',
'explain',
'ws-1'
)
const [ctx] = result
// Outer fence must be longer than the embedded ``` run, and the full snippet
// (including its inner fence) must survive intact.
expect(ctx.content).toContain('````')
expect(ctx.content).toContain(snippet)
expect(ctx.content.startsWith('Selected passage')).toBe(true)
expect(ctx.content.endsWith('````')).toBe(true)
})
})
describe('processContextsServer - table_selection contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('re-fetches rows by id and renders a markdown table for the selected columns', async () => {
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: {
columns: [
{ id: 'c_name', name: 'Name' },
{ id: 'c_amount', name: 'Amount' },
{ id: 'c_notes', name: 'Notes' },
],
},
})
getRowsByIds.mockResolvedValue([
{ id: 'r1', data: { c_name: 'Acme', c_amount: 100, c_notes: 'ignored' } },
{ id: 'r2', data: { c_name: 'Globex', c_amount: 250, c_notes: 'ignored' } },
])
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
label: 'Sales (2 rows, 2 cols)',
rowIds: ['r1', 'r2'],
columnIds: ['c_name', 'c_amount'],
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
expect(getRowsByIds).toHaveBeenCalledWith('tbl-1', ['r1', 'r2'], 'ws-1')
expect(result).toHaveLength(1)
const [ctx] = result
expect(ctx.type).toBe('table_selection')
expect(ctx.content).toContain('| Name | Amount |')
expect(ctx.content).toContain('| Acme | 100 |')
expect(ctx.content).toContain('| Globex | 250 |')
// Unselected column is excluded from the cell range.
expect(ctx.content).not.toContain('Notes')
expect(ctx.content).not.toContain('ignored')
})
it('drops the selection for a cross-workspace table', async () => {
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'other-ws',
schema: { columns: [] },
})
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
label: 'x',
rowIds: ['r1'],
} as ChatContext,
],
'user-1',
'hello',
'ws-1'
)
expect(getRowsByIds).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('drops a cell range whose columns no longer resolve (never expands to full table)', async () => {
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'c_name', name: 'Name' }] },
})
getRowsByIds.mockResolvedValue([{ id: 'r1', data: { c_name: 'Acme' } }])
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
label: 'Sales (1 row, 1 col)',
rowIds: ['r1'],
// Column was renamed/deleted since the selection was captured.
columnIds: ['c_deleted'],
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
expect(result).toEqual([])
})
it('keeps the whole rendered content within budget when rows pack tightly', async () => {
// Rows small enough to fill the budget almost exactly: the last accepted row
// leaves only a few characters of slack, so a budget that forgot to reserve
// the prose prefix and newlines overruns the cap here while passing on
// coarse fixtures that stop far short of the limit.
const cell = 'x'.repeat(100)
const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({
id: `r${i}`,
data: { c_notes: cell },
}))
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'c_notes', name: 'Notes' }] },
})
getRowsByIds.mockResolvedValue(rows)
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
tableName: 'Sales',
label: 'Sales (500 rows)',
rowIds: rows.map((r) => r.id),
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
const [ctx] = result
expect(ctx.content.length).toBeLessThanOrEqual(MAX_TABLE_SELECTION_CONTENT_LENGTH)
// Guard against passing by emitting almost nothing — it must still be a
// real table that genuinely approaches the cap.
expect(ctx.content.length).toBeGreaterThan(MAX_TABLE_SELECTION_CONTENT_LENGTH * 0.9)
expect(ctx.content).toContain('omitted for length')
})
it('holds the cap across cell widths, including ones that pack flush to it', async () => {
// A single width can leave slack that hides an under-reserved prefix by a
// few characters. Sweeping widths lands at least one run with almost no
// remainder, which is where an off-by-N in the reserve actually shows up.
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'c_notes', name: 'Notes' }] },
})
const overflows: Array<{ width: number; length: number }> = []
for (let width = 60; width <= 75; width++) {
const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({
id: `r${i}`,
data: { c_notes: 'x'.repeat(width) },
}))
getRowsByIds.mockResolvedValue(rows)
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
tableName: 'Sales',
label: 'Sales (500 rows)',
rowIds: rows.map((r) => r.id),
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
const { length } = result[0].content
if (length > MAX_TABLE_SELECTION_CONTENT_LENGTH) overflows.push({ width, length })
}
// Collected rather than asserted per-iteration so a failure names the widths.
expect(overflows).toEqual([])
})
it('spends a character budget across rows and reports what it omitted', async () => {
// Row/column caps alone don't bound prompt cost: wide cells blow past the
// budget long before MAX_TABLE_SELECTION_ROWS.
const wide = 'x'.repeat(2_000)
const rows = Array.from({ length: MAX_TABLE_SELECTION_ROWS }, (_, i) => ({
id: `r${i}`,
data: { c_notes: wide },
}))
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'c_notes', name: 'Notes' }] },
})
getRowsByIds.mockResolvedValue(rows)
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
tableName: 'Sales',
label: 'Sales (500 rows)',
rowIds: rows.map((r) => r.id),
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
const [ctx] = result
expect(ctx.content.length).toBeLessThanOrEqual(MAX_TABLE_SELECTION_CONTENT_LENGTH)
expect(ctx.content).toContain('omitted for length')
})
it('emits at least one row even when that row alone exceeds the budget', async () => {
const huge = 'x'.repeat(MAX_TABLE_SELECTION_CONTENT_LENGTH * 2)
getTableById.mockResolvedValue({
name: 'Sales',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'c_notes', name: 'Notes' }] },
})
getRowsByIds.mockResolvedValue([{ id: 'r1', data: { c_notes: huge } }])
const result = await processContextsServer(
[
{
kind: 'table_selection',
tableId: 'tbl-1',
tableName: 'Sales',
label: 'Sales (1 row)',
rowIds: ['r1'],
} as ChatContext,
],
'user-1',
'summarize',
'ws-1'
)
expect(result).toHaveLength(1)
expect(result[0].content).toContain(huge)
})
})
@@ -6,6 +6,10 @@ import {
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
import { and, eq, isNull, ne } from 'drizzle-orm'
import {
MAX_TABLE_SELECTION_CONTENT_LENGTH,
truncateSelectionText,
} from '@/lib/copilot/chat/selection-context'
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import {
@@ -23,7 +27,10 @@ import { toOverview } from '@/lib/logs/log-views'
import type { TraceSpan } from '@/lib/logs/types'
import { mcpService } from '@/lib/mcp/service'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getColumnId } from '@/lib/table/column-keys'
import { getRowsByIds } from '@/lib/table/rows/service'
import { getTableById } from '@/lib/table/service'
import type { ColumnDefinition } from '@/lib/table/types'
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getSkillById } from '@/lib/workflows/skills/operations'
@@ -41,7 +48,9 @@ type AgentContextType =
| 'logs'
| 'knowledge'
| 'table'
| 'table_selection'
| 'file'
| 'file_selection'
| 'workflow_block'
| 'docs'
| 'folder'
@@ -190,6 +199,31 @@ export async function processContextsServer(
path: result.path,
}
}
if (ctx.kind === 'file_selection' && ctx.fileId && currentWorkspaceId) {
return await resolveFileSelectionResource(
ctx.fileId,
currentWorkspaceId,
ctx.text ?? '',
ctx.label,
ctx.startLine,
ctx.endLine
)
}
if (
ctx.kind === 'table_selection' &&
ctx.tableId &&
Array.isArray(ctx.rowIds) &&
ctx.rowIds.length > 0 &&
currentWorkspaceId
) {
return await resolveTableSelectionResource(
ctx.tableId,
currentWorkspaceId,
ctx.rowIds,
ctx.columnIds,
ctx.label
)
}
if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) {
const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId)
if (!result) return null
@@ -847,6 +881,141 @@ 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<AgentContext | null> {
const record = await getWorkspaceFile(workspaceId, fileId)
if (!record) return null
const path = canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name })
const snippet = truncateSelectionText(text)
const lineRange =
startLine && endLine && endLine !== startLine
? ` (lines ${startLine}-${endLine})`
: startLine
? ` (line ${startLine})`
: ''
const fence = codeFenceFor(snippet)
const content = `Selected passage from ${record.name}${lineRange}:\n\n${fence}\n${snippet}\n${fence}`
return {
type: 'file_selection',
tag: label ? `@${label}` : '@',
content,
path,
}
}
/**
* Renders one cell for a markdown table row, escaping the delimiters.
*/
function renderTableCell(value: unknown): string {
if (value === null || value === undefined) return ''
const cell = typeof value === 'string' ? value : JSON.stringify(value)
return cell.replace(/\|/g, '\\|').replace(/\n/g, ' ')
}
/**
* Resolves a table selection into an inline markdown table. Rows are re-fetched
* by id from the DB (never trusting client-sent cell values); when `columnIds`
* is present the projection is narrowed to that cell range, otherwise every
* column is included. Output is bounded by
* {@link MAX_TABLE_SELECTION_CONTENT_LENGTH}, not just the row and column caps.
*/
async function resolveTableSelectionResource(
tableId: string,
workspaceId: string,
rowIds: string[],
columnIds: string[] | undefined,
label: string
): Promise<AgentContext | null> {
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 scope = hasColumnScope ? 'cell range' : 'rows'
const describe = (size: string) =>
`Selected ${scope} from table "${table.name}" (${size}):\n\n${header}\n${divider}\n`
/**
* The size clause, e.g. `5 rows` or `189 rows of 500, 311 omitted for length`.
* Used for both the up-front reserve and the final prose, so the two can never
* describe the row count differently.
*/
const sizeClause = (shownCount: number, omittedCount: number) => {
const shown = `${shownCount} ${shownCount === 1 ? 'row' : 'rows'}`
return omittedCount > 0
? `${shown} of ${rows.length}, ${omittedCount} omitted for length`
: shown
}
// Spend the character budget row by row. Everything that is not a row — the
// prose, the table head, and every newline — is reserved up front, or the cap
// is silently overrun whenever the last row leaves less slack than the prefix
// needs. The real clause isn't known until packing finishes, so reserve its
// longest form: every row shown AND every row omitted maximizes both counts
// and forces the plural. A few characters of unused slack beats overshooting.
const lines: string[] = []
let remaining =
MAX_TABLE_SELECTION_CONTENT_LENGTH - describe(sizeClause(rows.length, rows.length)).length
for (const row of rows) {
const line = `| ${columns.map((col) => renderTableCell(row.data[getColumnId(col)])).join(' | ')} |`
// The first row always goes in, so a single oversized row still yields a
// table rather than an empty one.
if (lines.length > 0 && line.length + 1 > remaining) break
lines.push(line)
remaining -= line.length + 1
}
const content = `${describe(sizeClause(lines.length, rows.length - lines.length))}${lines.join('\n')}`
return {
type: 'table_selection',
tag: label ? `@${label}` : '@',
content,
path: canonicalTableVfsPath(table.name),
}
}
async function resolveFileFolderResource(
folderId: string,
workspaceId: string
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ChatContext } from '@/stores/panel'
import {
attachSelectionContextToClipboard,
readSelectionContextFromClipboard,
SIM_SELECTION_MIME,
} from './selection-clipboard'
/** Minimal DataTransfer stand-in (jsdom-free node env). */
function fakeClipboard(initial: Record<string, string> = {}) {
const store: Record<string, string> = { ...initial }
return {
setData: (type: string, value: string) => {
store[type] = value
},
getData: (type: string) => store[type] ?? '',
} as unknown as DataTransfer
}
const fileSelection: ChatContext = {
kind: 'file_selection',
fileId: 'wf_1',
fileName: 'notes.md',
label: 'notes.md:2-4',
text: 'the exact passage',
startLine: 2,
endLine: 4,
}
const tableSelection: ChatContext = {
kind: 'table_selection',
tableId: 'tbl_1',
tableName: 'Sales',
label: 'Sales (2 rows)',
rowIds: ['r1', 'r2'],
}
describe('selection clipboard codec', () => {
it('round-trips a file selection through the custom MIME type', () => {
const dt = fakeClipboard()
attachSelectionContextToClipboard(dt, fileSelection)
expect(dt.getData(SIM_SELECTION_MIME)).toContain('file_selection')
expect(readSelectionContextFromClipboard(dt)).toEqual(fileSelection)
})
it('round-trips a table selection', () => {
const dt = fakeClipboard()
attachSelectionContextToClipboard(dt, tableSelection)
expect(readSelectionContextFromClipboard(dt)).toEqual(tableSelection)
})
it('does not touch text/plain (rides alongside it)', () => {
const dt = fakeClipboard({ 'text/plain': 'the exact passage' })
attachSelectionContextToClipboard(dt, fileSelection)
expect(dt.getData('text/plain')).toBe('the exact passage')
})
it('returns null when the custom type is absent (plain paste)', () => {
expect(readSelectionContextFromClipboard(fakeClipboard({ 'text/plain': 'hi' }))).toBeNull()
})
it('returns null on malformed JSON', () => {
expect(
readSelectionContextFromClipboard(fakeClipboard({ [SIM_SELECTION_MIME]: '{not json' }))
).toBeNull()
})
it('rejects a file selection missing its text', () => {
const dt = fakeClipboard({
[SIM_SELECTION_MIME]: JSON.stringify({
kind: 'file_selection',
fileId: 'wf_1',
fileName: 'notes.md',
label: 'x',
}),
})
expect(readSelectionContextFromClipboard(dt)).toBeNull()
})
it('rejects a selection missing the resource name the chip renders from', () => {
const dt = fakeClipboard({
[SIM_SELECTION_MIME]: JSON.stringify({
kind: 'file_selection',
fileId: 'wf_1',
label: 'x',
text: 'passage',
}),
})
expect(readSelectionContextFromClipboard(dt)).toBeNull()
})
it('rejects a table selection with no rows', () => {
const dt = fakeClipboard({
[SIM_SELECTION_MIME]: JSON.stringify({
kind: 'table_selection',
tableId: 'tbl_1',
tableName: 'Sales',
label: 'x',
rowIds: [],
}),
})
expect(readSelectionContextFromClipboard(dt)).toBeNull()
})
it('rejects an unrelated context kind', () => {
const dt = fakeClipboard({
[SIM_SELECTION_MIME]: JSON.stringify({ kind: 'file', fileId: 'wf_1', label: 'x' }),
})
expect(readSelectionContextFromClipboard(dt)).toBeNull()
})
})
@@ -0,0 +1,65 @@
import type { ChatContext } from '@/stores/panel'
/**
* Custom clipboard MIME type carrying a selection {@link ChatContext} so a
* highlighted passage copied from a file/table can be pasted into the Chat input
* as a reference chip. Written alongside `text/plain` (never replacing it), so
* pasting anywhere else still yields the plain selection text.
*/
export const SIM_SELECTION_MIME = 'text/x-sim-selection'
/**
* Attaches a selection context to a copy event's clipboard. Adds the custom MIME
* type WITHOUT calling `preventDefault`, so the editor's own copy handler (Monaco,
* ProseMirror) still writes `text/plain`/`text/html` — the custom type simply
* rides along on the shared `DataTransfer`.
*/
export function attachSelectionContextToClipboard(
clipboardData: DataTransfer | null,
context: ChatContext
): void {
if (!clipboardData) return
try {
clipboardData.setData(SIM_SELECTION_MIME, JSON.stringify(context))
} catch {
// Some browsers reject custom types mid-gesture; degrade to plain-text copy.
}
}
/**
* Reads a selection context previously written by
* {@link attachSelectionContextToClipboard}, or null when the clipboard carries
* no (or an invalid) selection payload.
*/
export function readSelectionContextFromClipboard(
clipboardData: DataTransfer | null
): ChatContext | null {
const raw = clipboardData?.getData(SIM_SELECTION_MIME)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as ChatContext
if (!parsed || typeof parsed.label !== 'string') return null
// Require each kind's resolving field so a chip never pastes only to
// resolve to nothing server-side.
if (
parsed.kind === 'file_selection' &&
typeof parsed.text === 'string' &&
typeof parsed.fileName === 'string' &&
parsed.fileId
) {
return parsed
}
if (
parsed.kind === 'table_selection' &&
parsed.tableId &&
typeof parsed.tableName === 'string' &&
Array.isArray(parsed.rowIds) &&
parsed.rowIds.length > 0
) {
return parsed
}
} catch {
// Malformed payload — fall back to plain-text paste.
}
return null
}
@@ -0,0 +1,57 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildFileSelectionLabel,
buildTableSelectionLabel,
MAX_FILE_SELECTION_TEXT_LENGTH,
truncateSelectionText,
} from './selection-context'
describe('buildFileSelectionLabel', () => {
it('renders a line range', () => {
expect(buildFileSelectionLabel('notes.md', 12, 40)).toBe('notes.md:12-40')
})
it('renders a single line when start equals end', () => {
expect(buildFileSelectionLabel('notes.md', 12, 12)).toBe('notes.md:12')
})
it('renders a single line when only start is known', () => {
expect(buildFileSelectionLabel('notes.md', 12)).toBe('notes.md:12')
})
it('marks a line-less selection so it cannot collide with the whole-file chip', () => {
// A bare 'notes.md' would equal the whole-file chip's label, and menu inserts
// silently reject an already-taken label — blocking the file's own mention.
expect(buildFileSelectionLabel('notes.md')).toBe('notes.md (selection)')
expect(buildFileSelectionLabel('notes.md')).not.toBe('notes.md')
})
})
describe('buildTableSelectionLabel', () => {
it('pluralizes rows and omits columns for whole-row selections', () => {
expect(buildTableSelectionLabel('Sales', 5)).toBe('Sales (5 rows)')
expect(buildTableSelectionLabel('Sales', 1)).toBe('Sales (1 row)')
})
it('includes column count for a cell range', () => {
expect(buildTableSelectionLabel('Sales', 5, 3)).toBe('Sales (5 rows, 3 cols)')
expect(buildTableSelectionLabel('Sales', 2, 1)).toBe('Sales (2 rows, 1 col)')
})
})
describe('truncateSelectionText', () => {
it('leaves text within the bound untouched', () => {
expect(truncateSelectionText('short passage')).toBe('short passage')
})
it('keeps the result — ellipsis included — within the server schema bound', () => {
const oversized = 'x'.repeat(MAX_FILE_SELECTION_TEXT_LENGTH + 500)
const truncated = truncateSelectionText(oversized)
expect(truncated.length).toBeLessThanOrEqual(MAX_FILE_SELECTION_TEXT_LENGTH)
expect(truncated.length).toBeLessThan(oversized.length)
})
})
@@ -0,0 +1,84 @@
/**
* Shared bounds and label helpers for selection-scoped chat contexts
* (`file_selection`, `table_selection`). Kept free of server-only imports so
* both the client producers (file/table viewers) and the server validator /
* resolver can consume the same limits and formatting.
*/
import { truncate } from '@sim/utils/string'
/**
* Max characters of selected file text carried inline on a `file_selection`.
* This is the ceiling on the FINAL serialized string, matched by the server
* schema's `.max(...)`; always truncate through {@link truncateSelectionText}
* so the trailing ellipsis can't push the value past this bound.
*/
export const MAX_FILE_SELECTION_TEXT_LENGTH = 20_000
/** Max rows referenced by a single `table_selection`. */
export const MAX_TABLE_SELECTION_ROWS = 500
/** Max columns referenced by a `table_selection` cell range. */
export const MAX_TABLE_SELECTION_COLUMNS = 200
/**
* Max characters of rendered markdown a `table_selection` contributes to the
* prompt. Row and column caps alone don't bound this — 500 rows of wide cells
* dwarf a file selection — so the renderer spends this budget and reports what
* it dropped. Deliberately equal to {@link MAX_FILE_SELECTION_TEXT_LENGTH} so
* both selection kinds cost the prompt the same at worst.
*/
export const MAX_TABLE_SELECTION_CONTENT_LENGTH = MAX_FILE_SELECTION_TEXT_LENGTH
/** Length of the ellipsis {@link truncate} appends when it shortens a string. */
const TRUNCATE_SUFFIX_LENGTH = 3
/**
* Truncates selected file text so the RESULT (including the appended ellipsis)
* never exceeds {@link MAX_FILE_SELECTION_TEXT_LENGTH} — keeping the client
* payload within the server schema bound, which otherwise rejects the whole
* chat request.
*/
export function truncateSelectionText(text: string): string {
return truncate(text, MAX_FILE_SELECTION_TEXT_LENGTH - TRUNCATE_SUFFIX_LENGTH)
}
/**
* Builds the IDE-style chip label for a file selection, e.g. `notes.md:12-40` or
* `notes.md:12`. Without a line range — the rich-markdown editor, whose document
* model has no source lines — it falls back to `notes.md (selection)`.
*
* That suffix is load-bearing, not decoration: a bare file name would be
* identical to the whole-file `@notes.md` chip's label, and menu-driven inserts
* reject any context whose label is already taken (`isContextAlreadySelected`).
* A markdown selection would then silently block mentioning its own file.
*
* Kept ASCII so the label survives being inserted as an inline mention token in
* the chat input. Labels must be unique across a message's chips — the caller
* resolves collisions through `uniqueContextLabel`.
*/
export function buildFileSelectionLabel(
fileName: string,
startLine?: number,
endLine?: number
): string {
if (!startLine) return `${fileName} (selection)`
const range = endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}`
return `${fileName}:${range}`
}
/**
* Builds the chip label for a table selection, e.g. `Sales (5 rows)` or, for a
* cell range, `Sales (5 rows, 3 cols)`. ASCII-only, and collision-resolved by
* the caller, for the same reasons as {@link buildFileSelectionLabel}.
*/
export function buildTableSelectionLabel(
tableName: string,
rowCount: number,
columnCount?: number
): string {
const rows = `${rowCount} ${rowCount === 1 ? 'row' : 'rows'}`
if (!columnCount) return `${tableName} (${rows})`
const cols = `${columnCount} ${columnCount === 1 ? 'col' : 'cols'}`
return `${tableName} (${rows}, ${cols})`
}
@@ -7,6 +7,11 @@ import type { ChatContext } from '@/stores/panel'
const WS = 'ws-1'
/** A chip-only handoff context — the highlight-to-chat payload. */
function chipContext(): ChatContext {
return { kind: 'file_selection', fileId: 'f1', fileName: 'a.md', label: 'a.md', text: 'a' }
}
describe('MothershipHandoffStorage', () => {
beforeEach(() => {
localStorage.clear()
@@ -26,8 +31,10 @@ describe('MothershipHandoffStorage', () => {
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
})
it('refuses to store without a message or workspace', () => {
it('refuses to store without a workspace, or with neither a message nor a context', () => {
expect(MothershipHandoffStorage.store({ message: ' ' }, WS)).toBe(false)
expect(MothershipHandoffStorage.store({ contexts: [] }, WS)).toBe(false)
expect(MothershipHandoffStorage.store({}, WS)).toBe(false)
expect(MothershipHandoffStorage.store({ message: 'fix it' }, '')).toBe(false)
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
})
@@ -40,7 +47,74 @@ describe('MothershipHandoffStorage', () => {
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).not.toBeNull()
// The owning workspace still consumes it.
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: undefined })
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: [] })
})
it('stores a chip-only handoff (no message) and returns it without one', () => {
const contexts: ChatContext[] = [
{
kind: 'file_selection',
fileId: 'f1',
fileName: 'notes.md',
label: 'notes.md:2-4',
text: 'passage',
},
]
expect(MothershipHandoffStorage.store({ contexts }, WS)).toBe(true)
expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts })
})
it('accumulates chip-only handoffs so a second add before navigation is not dropped', () => {
const first = chipContext()
const second: ChatContext = {
kind: 'table_selection',
tableId: 't1',
tableName: 'T',
label: 'T (1 row)',
rowIds: ['r'],
}
MothershipHandoffStorage.store({ contexts: [first] }, WS)
MothershipHandoffStorage.store({ contexts: [second] }, WS)
expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts: [first, second] })
})
it('does not revive chips from a handoff that already aged out', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
const abandoned = chipContext()
MothershipHandoffStorage.store({ contexts: [abandoned] }, WS)
// The user walks away; the handoff expires in place. A later "Add to chat"
// stamps a fresh timestamp, which must not carry the dead chip forward.
vi.advanceTimersByTime(61 * 1000)
const fresh: ChatContext = {
kind: 'table_selection',
tableId: 't1',
tableName: 'T',
label: 'T (1 row)',
rowIds: ['r'],
}
MothershipHandoffStorage.store({ contexts: [fresh] }, WS)
expect(MothershipHandoffStorage.consume(WS)).toEqual({ contexts: [fresh] })
} finally {
vi.useRealTimers()
}
})
it('does not accumulate chips onto a message handoff, or across workspaces', () => {
const chip = chipContext()
MothershipHandoffStorage.store({ contexts: [chip] }, WS)
// A message handoff replaces rather than inheriting the pending chips.
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: [] })
MothershipHandoffStorage.store({ contexts: [chip] }, WS)
MothershipHandoffStorage.store({ contexts: [chip] }, 'ws-other')
expect(MothershipHandoffStorage.consume('ws-other')).toEqual({ contexts: [chip] })
})
it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
+59 -23
View File
@@ -300,17 +300,28 @@ export class LandingWorkflowSeedStorage {
}
export interface MothershipHandoff {
/** The message to auto-send to Chat once the home surface mounts. */
message: string
/**
* Message to auto-send once the home surface mounts. Omit for a chip-only
* handoff, which seeds `contexts` into the input and waits for the user.
*/
message?: string
/** Structured contexts to attach — e.g. a `logs` mention tagging a run. */
contexts?: ChatContext[]
}
interface StoredHandoff extends MothershipHandoff {
workspaceId?: string
timestamp?: number
}
/**
* One-shot handoff that seeds an auto-sent Chat (mothership) message when the
* user is routed to the workspace home from elsewhere in the app — e.g. the
* "Troubleshoot in Chat" action on an errored log, which tags the failed run
* and asks Sim to fix it.
* One-shot handoff that seeds Chat (mothership) when the user is routed to the
* workspace home from elsewhere in the app. Two shapes share this slot:
*
* - **With a message** — auto-sent on mount, e.g. the "Troubleshoot in Chat"
* action on an errored log, which tags the failed run and asks Sim to fix it.
* - **Chips only** — the highlight-to-chat action on the standalone Files and
* Tables pages, which attaches reference chips and sends nothing.
*
* The home surface consumes this exactly once on mount. A short max-age guards
* against a stale handoff firing on a later, unrelated visit, and `consume`
@@ -319,40 +330,64 @@ export interface MothershipHandoff {
export class MothershipHandoffStorage {
private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF
/** How long a stored handoff stays eligible to fire, in milliseconds. */
static readonly MAX_AGE_MS = 60 * 1000
/**
* Store a handoff to be auto-sent on the next home-surface mount, scoped to
* the workspace it targets so a different workspace never claims it.
* @returns True if stored, false when the message or workspace is empty.
* Store a handoff for the next home-surface mount, scoped to the workspace it
* targets so a different workspace never claims it. Chip-only handoffs
* accumulate — "Add to chat" can fire twice before the route swap completes,
* and the second write must not drop the first.
* @returns True if stored, false when the workspace is empty or the handoff
* carries neither a message nor a context.
*/
static store(handoff: MothershipHandoff, workspaceId: string): boolean {
const message = handoff.message.trim()
if (!message || !workspaceId) {
const message = handoff.message?.trim()
const contexts = handoff.contexts ?? []
if (!workspaceId || (!message && contexts.length === 0)) {
return false
}
return BrowserStorage.setItem(MothershipHandoffStorage.KEY, {
message,
contexts: handoff.contexts,
...(message ? { message } : {}),
contexts: message
? contexts
: [...MothershipHandoffStorage.pendingContexts(workspaceId), ...contexts],
workspaceId,
timestamp: Date.now(),
})
}
/**
* Contexts of an un-consumed chip-only handoff for `workspaceId`, else empty.
*
* Applies the same freshness bar as {@link consume}: accumulating carries the
* old contexts onto a write that stamps a new `timestamp`, so without this an
* abandoned handoff that had already aged out would ride along on the next
* "Add to chat" and reappear as if it were current.
*/
private static pendingContexts(workspaceId: string): ChatContext[] {
const data = BrowserStorage.getItem<StoredHandoff | null>(MothershipHandoffStorage.KEY, null)
if (!data || data.message || data.workspaceId !== workspaceId) return []
if (!data.timestamp || Date.now() - data.timestamp > MothershipHandoffStorage.MAX_AGE_MS) {
return []
}
return Array.isArray(data.contexts) ? data.contexts : []
}
/**
* Retrieve and consume the stored handoff for `workspaceId`. A handoff owned
* by a different workspace is left untouched for its owner — its tagged run
* only resolves in its own workspace, so misfiring it elsewhere would drop the
* context. The owner (and any legacy/corrupt entry) is tombstoned via `clear`
* before the validity/expiry checks so it fires at most once and never lingers.
* @param maxAge - Maximum age in milliseconds (default: 60 seconds)
* @param maxAge - Maximum age in milliseconds (default: {@link MAX_AGE_MS})
*/
static consume(workspaceId: string, maxAge: number = 60 * 1000): MothershipHandoff | null {
const data = BrowserStorage.getItem<{
message?: string
contexts?: ChatContext[]
workspaceId?: string
timestamp?: number
} | null>(MothershipHandoffStorage.KEY, null)
static consume(
workspaceId: string,
maxAge: number = MothershipHandoffStorage.MAX_AGE_MS
): MothershipHandoff | null {
const data = BrowserStorage.getItem<StoredHandoff | null>(MothershipHandoffStorage.KEY, null)
if (!data) {
return null
@@ -364,16 +399,17 @@ export class MothershipHandoffStorage {
MothershipHandoffStorage.clear()
const contexts = Array.isArray(data.contexts) ? data.contexts : []
if (
!data.workspaceId ||
!data.message ||
(!data.message && contexts.length === 0) ||
!data.timestamp ||
Date.now() - data.timestamp > maxAge
) {
return null
}
return { message: data.message, contexts: data.contexts }
return { ...(data.message ? { message: data.message } : {}), contexts }
}
static clear(): boolean {
+53 -12
View File
@@ -3,6 +3,16 @@ import type { ChatContext } from '@/stores/panel'
const logger = createLogger('MothershipEvents')
/**
* Dispatches a cancelable window event and reports whether a mounted consumer
* claimed it. Consumers claim by calling `preventDefault`, which makes
* `dispatchEvent` return `false` — so an unclaimed event is one no listener
* handled, and the producer falls back to persisting a handoff.
*/
function dispatchClaimable<T>(name: string, detail: T): boolean {
return !window.dispatchEvent(new CustomEvent<T>(name, { detail, cancelable: true }))
}
/**
* Custom-event name used to send a user message to the Mothership chat.
* The mothership host components (workspace home, workflow panel) listen
@@ -19,9 +29,7 @@ export interface MothershipSendMessageDetail {
/**
* Dispatches a message to a mounted Mothership chat. Producers (terminal block
* errors, console copilot actions, toast actions, the log "Troubleshoot in
* Chat" action) call this; consumers listen for
* {@link MOTHERSHIP_SEND_MESSAGE_EVENT} on `window` and `preventDefault` to
* claim it.
* Chat" action) call this.
*
* @returns `true` when a mounted host consumed the message, `false` when none
* was listening — callers that can fall back (e.g. cross-route navigation) use
@@ -33,15 +41,48 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[])
logger.warn('sendMothershipMessage called with empty message')
return false
}
const consumed = !window.dispatchEvent(
new CustomEvent<MothershipSendMessageDetail>(MOTHERSHIP_SEND_MESSAGE_EVENT, {
detail: { message: trimmed, contexts },
cancelable: true,
})
)
logger.info('Dispatched mothership message event', {
messageLength: trimmed.length,
consumed,
const consumed = dispatchClaimable<MothershipSendMessageDetail>(MOTHERSHIP_SEND_MESSAGE_EVENT, {
message: trimmed,
contexts,
})
logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed })
return consumed
}
/**
* Custom-event name used to attach a context chip to the Mothership chat input
* WITHOUT sending a message. The mounted chat input listens for this and inserts
* the chip, leaving the user to type their prompt and send when ready.
*
* Kept separate from {@link MOTHERSHIP_SEND_MESSAGE_EVENT} because the consumer
* differs: "send now" is claimed by the chat host, "attach a chip" by the input.
* Folding both into one event would make two listeners race to claim it.
*/
export const MOTHERSHIP_ADD_CONTEXT_EVENT = 'mothership-add-context'
export interface MothershipAddContextDetail {
/** The contexts to attach as chips, in insertion order. */
contexts: ChatContext[]
}
/**
* Dispatches a passive "add these context chips" request to a mounted Mothership
* chat input — the highlight-to-chat action in the file and table viewers.
*
* Carries the whole batch in one event rather than one event per context: the
* input resolves label collisions against its current chips, and that list only
* refreshes on re-render, so consecutive synchronous dispatches would each see
* the same stale list and drop colliding chips.
*
* @returns `true` when a mounted input consumed it, `false` when none was
* listening — callers fall back to persisting a chip-only handoff (see
* `MothershipHandoffStorage`) for the next chat mount.
*/
export function addMothershipContexts(contexts: ChatContext[]): boolean {
if (contexts.length === 0) return false
const consumed = dispatchClaimable<MothershipAddContextDetail>(MOTHERSHIP_ADD_CONTEXT_EVENT, {
contexts,
})
logger.info('Dispatched mothership add-context event', { count: contexts.length, consumed })
return consumed
}
+30
View File
@@ -1375,6 +1375,36 @@ export async function getRowById(
}
}
/**
* Fetches the `data` payloads for a set of rows by id, scoped to a table and
* workspace. Returns lightweight `{ id, data }` records (no executions) in the
* order the ids were requested, silently skipping ids that don't resolve. Used
* to materialize a `table_selection` chat context server-side so the agent gets
* fresh, authoritative cell values instead of trusting client-sent copies.
*/
export async function getRowsByIds(
tableId: string,
rowIds: string[],
workspaceId: string
): Promise<Array<{ id: string; data: RowData }>> {
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 {
+38
View File
@@ -24,7 +24,45 @@ export type ChatContext =
| { kind: 'workflow_block'; workflowId: string; blockId: string; label: string }
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'table'; tableId: string; label: string }
| {
kind: 'table_selection'
tableId: string
label: string
/**
* Name of the table the selection came from. Carried explicitly rather
* than parsed back out of `label`, which is a display string the input may
* rewrite to keep chip tokens unique.
*/
tableName: string
/** Materialized from the grid selection, including rows not yet paged in. */
rowIds: string[]
/**
* Ids of the selected columns. Present only for a spreadsheet-style cell
* range; absent when whole rows are selected.
*/
columnIds?: string[]
}
| { kind: 'file'; fileId: string; label: string }
| {
kind: 'file_selection'
fileId: string
label: string
/** Name of the file the selection came from. See `tableName` above. */
fileName: string
/**
* The literal selected text. Carried inline rather than re-read
* server-side because the editor may hold unsaved changes — re-reading
* would hand the agent different bytes than the user highlighted.
*/
text: string
/**
* 1-based inclusive line range, present only when the source has real
* line numbers (Monaco). The rich-markdown editor's document model has no
* source lines, so it omits these rather than approximating them.
*/
startLine?: number
endLine?: number
}
| { kind: 'folder'; folderId: string; label: string }
| { kind: 'filefolder'; fileFolderId: string; label: string }
| { kind: 'scheduledtask'; scheduleId: string; label: string }