Skip to content

Commit e9e9a8d

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(chat): scope selection clipboard chips to workspace
1 parent eefbb20 commit e9e9a8d

12 files changed

Lines changed: 176 additions & 51 deletions

File tree

apps/sim/app/_shell/paste-admission-guard.test.tsx

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
1717
let host: HTMLDivElement
1818
let root: Root
1919

20+
const selectionContext = {
21+
kind: 'table_selection',
22+
tableId: 'table-1',
23+
tableName: 'Large table',
24+
rowIds: ['row-1'],
25+
label: 'Large table (1 row)',
26+
}
27+
28+
function selectionPayload(sourceWorkspaceId = 'ws-1'): string {
29+
return JSON.stringify({ version: 1, sourceWorkspaceId, context: selectionContext })
30+
}
31+
2032
function dispatchPaste(
2133
target: Element,
2234
text: string,
@@ -116,32 +128,33 @@ describe('PasteAdmissionGuard', () => {
116128
it('lets a prompt consume a compact Sim selection reference before its large plain text', () => {
117129
const input = document.createElement('textarea')
118130
input.dataset.pasteMaxBytes = '4'
119-
input.dataset.pasteSelectionContext = 'reference'
131+
input.dataset.pasteSelectionContext = 'ws-1'
132+
host.appendChild(input)
133+
134+
expect(
135+
dispatchPaste(input, '12345', { selectionContext: selectionPayload() }).defaultPrevented
136+
).toBe(false)
137+
})
138+
139+
it('still bounds a cross-workspace selection plain-text representation', () => {
140+
const input = document.createElement('textarea')
141+
input.dataset.pasteMaxBytes = '4'
142+
input.dataset.pasteSelectionContext = 'ws-2'
120143
host.appendChild(input)
121-
const selectionContext = JSON.stringify({
122-
kind: 'table_selection',
123-
tableId: 'table-1',
124-
tableName: 'Large table',
125-
rowIds: ['row-1'],
126-
label: 'Large table (1 row)',
127-
})
128144

129-
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false)
145+
expect(
146+
dispatchPaste(input, '12345', { selectionContext: selectionPayload() }).defaultPrevented
147+
).toBe(true)
130148
})
131149

132150
it('still bounds a Sim selection plain-text representation outside the prompt', () => {
133151
const input = document.createElement('textarea')
134152
input.dataset.pasteMaxBytes = '4'
135153
host.appendChild(input)
136-
const selectionContext = JSON.stringify({
137-
kind: 'table_selection',
138-
tableId: 'table-1',
139-
tableName: 'Large table',
140-
rowIds: ['row-1'],
141-
label: 'Large table (1 row)',
142-
})
143154

144-
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(true)
155+
expect(
156+
dispatchPaste(input, '12345', { selectionContext: selectionPayload() }).defaultPrevented
157+
).toBe(true)
145158
})
146159

147160
it('bounds rich HTML separately from its smaller plain-text representation', () => {

apps/sim/app/_shell/paste-admission-guard.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ export function PasteAdmissionGuard() {
4343
}
4444

4545
const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]')
46-
if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return
46+
const destinationWorkspaceId = acceptsSelectionContext?.getAttribute(
47+
'data-paste-selection-context'
48+
)
49+
if (
50+
destinationWorkspaceId &&
51+
readSelectionContextFromClipboard(event.clipboardData, destinationWorkspaceId)
52+
) {
53+
return
54+
}
4755

4856
const handlesImageFiles = event.target.closest('[data-paste-handles-images="true"]')
4957
if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1213,7 +1213,7 @@ export function LoadedRichMarkdownEditor({
12131213
if (context) addToChat(context)
12141214
}
12151215

1216-
useSelectionCopyBridge(containerRef, buildSelectionContext)
1216+
useSelectionCopyBridge(containerRef, buildSelectionContext, workspaceId)
12171217

12181218
// Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet
12191219
// seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ export const TextEditor = memo(function TextEditor({
493493

494494
// Enable once content has loaded — the container (and Monaco) only mount after
495495
// the `isContentLoading` early return below, so the bridge must (re-)attach then.
496-
useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading)
496+
useSelectionCopyBridge(containerRef, buildSelectionContext, workspaceId, !isContentLoading)
497497

498498
useEffect(() => {
499499
if (lastEditorValueRef.current === content) return

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ let buildContext: ReturnType<typeof vi.fn>
2626
* textarea, and its find widget is a real input nested in the same container.
2727
*/
2828
function Host() {
29-
useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null)
29+
useSelectionCopyBridge(containerRef, buildContext as () => ChatContext | null, 'ws-1')
3030
return (
3131
<div ref={containerRef}>
3232
<textarea id='editor-surface' />
@@ -74,7 +74,10 @@ describe('useSelectionCopyBridge', () => {
7474
const written = dispatchCopy('editor-surface')
7575

7676
expect(buildContext).toHaveBeenCalled()
77-
expect(written[SIM_SELECTION_MIME]).toContain('file_selection')
77+
expect(JSON.parse(written[SIM_SELECTION_MIME])).toMatchObject({
78+
sourceWorkspaceId: 'ws-1',
79+
context: selection,
80+
})
7881
})
7982

8083
it('ignores a copy from a nested input such as the find box', () => {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import type { ChatContext } from '@/stores/panel'
1313
* `text/plain`, so the custom type must be added last to survive.
1414
*
1515
* @param buildContext - Returns null when there is no non-empty selection.
16+
* @param workspaceId - Workspace that owns the selected resource.
1617
* @param enabled - Re-runs the effect for a container that mounts late (behind a
1718
* loading gate); a ref isn't reactive, so the effect would otherwise bail on the
1819
* first render and never re-attach.
1920
*/
2021
export function useSelectionCopyBridge(
2122
containerRef: RefObject<HTMLElement | null>,
2223
buildContext: () => ChatContext | null,
24+
workspaceId: string,
2325
enabled = true
2426
): void {
2527
useEffect(() => {
@@ -36,9 +38,9 @@ export function useSelectionCopyBridge(
3638
// main copy path this hook exists for.
3739
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return
3840
const context = buildContext()
39-
if (context) attachSelectionContextToClipboard(e.clipboardData, context)
41+
if (context) attachSelectionContextToClipboard(e.clipboardData, context, workspaceId)
4042
}
4143
dom.addEventListener('copy', onCopy)
4244
return () => dom.removeEventListener('copy', onCopy)
43-
}, [containerRef, buildContext, enabled])
45+
}, [containerRef, buildContext, workspaceId, enabled])
4446
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export function PromptEditor({
253253
onPaste={readOnly ? undefined : editor.handlePaste}
254254
data-paste-max-bytes={PASTE_LIMITS.CHAT_BYTES}
255255
data-paste-max-characters={PASTE_LIMITS.CHAT_CHARACTERS}
256-
data-paste-selection-context='reference'
256+
data-paste-selection-context={editor.workspaceId}
257257
onCopy={editor.handleCopy}
258258
onCut={readOnly ? undefined : editor.handleCut}
259259
onSelect={readOnly ? undefined : editor.handleSelectAdjust}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
import type { SkillsMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown'
2121
import type { ChatContext } from '@/stores/panel'
2222

23+
function selectionPayload(context: ChatContext, sourceWorkspaceId = 'ws-1'): string {
24+
return JSON.stringify({ version: 1, sourceWorkspaceId, context })
25+
}
26+
2327
/**
2428
* Mounts `usePromptEditor` in a real React 19 root under jsdom (no
2529
* `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx`
@@ -300,7 +304,7 @@ describe('usePromptEditor context insertion', () => {
300304
clipboardData: {
301305
getData: (type: string) => {
302306
if (type === 'text/plain') return 'x'.repeat(1_000_001)
303-
if (type === SIM_SELECTION_MIME) return JSON.stringify(context)
307+
if (type === SIM_SELECTION_MIME) return selectionPayload(context)
304308
return ''
305309
},
306310
},
@@ -315,6 +319,37 @@ describe('usePromptEditor context insertion', () => {
315319
unmount()
316320
})
317321

322+
it('leaves a cross-workspace selection to the ordinary plain-text paste path', () => {
323+
const context = {
324+
kind: 'file_selection',
325+
fileId: 'file-1',
326+
fileName: 'notes.md',
327+
label: 'notes.md:1',
328+
text: 'ordinary text',
329+
} satisfies ChatContext
330+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-2' })
331+
const preventDefault = vi.fn()
332+
333+
act(() => {
334+
result().handlePaste({
335+
currentTarget: textarea,
336+
clipboardData: {
337+
getData: (type: string) => {
338+
if (type === 'text/plain') return context.text
339+
if (type === SIM_SELECTION_MIME) return selectionPayload(context)
340+
return ''
341+
},
342+
},
343+
preventDefault,
344+
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>)
345+
})
346+
347+
expect(preventDefault).not.toHaveBeenCalled()
348+
expect(result().contexts).toEqual([])
349+
350+
unmount()
351+
})
352+
318353
it('suffixes duplicate visible labels so two browser selections coexist', () => {
319354
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
320355
callback(0)

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export function usePromptEditor({
174174
const [value, setValueState] = useState(initialValue)
175175
const valueRef = useRef(value)
176176
valueRef.current = value
177+
const workspaceIdRef = useRef(workspaceId)
178+
workspaceIdRef.current = workspaceId
177179

178180
/**
179181
* Commits a new text value, keeping {@link valueRef} in lockstep with state so
@@ -1000,7 +1002,10 @@ export function usePromptEditor({
10001002
// is already attached there is nothing to add, and claiming the event anyway
10011003
// would swallow the keystroke entirely — no chip and no text. Falling through
10021004
// pastes the selection's plain text, which is what the user asked for.
1003-
const selectionContext = readSelectionContextFromClipboard(e.clipboardData)
1005+
const selectionContext = readSelectionContextFromClipboard(
1006+
e.clipboardData,
1007+
workspaceIdRef.current
1008+
)
10041009
const preparedSelection = selectionContext
10051010
? prepareContextForInsert(selectionContext, contextManagementRef.current.selectedContexts)
10061011
: null
@@ -1151,7 +1156,11 @@ export function usePromptEditor({
11511156
if (soleSelectionChip) {
11521157
e.preventDefault()
11531158
e.clipboardData.setData('text/plain', selected)
1154-
attachSelectionContextToClipboard(e.clipboardData, selectionChips[0])
1159+
attachSelectionContextToClipboard(
1160+
e.clipboardData,
1161+
selectionChips[0],
1162+
workspaceIdRef.current
1163+
)
11551164
return true
11561165
}
11571166
const serialized = serializeSelectionForClipboard(selected, contexts)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ function cellToText(value: unknown, column?: DisplayColumn): string {
344344
*/
345345
function writeLoadedRowsWithChip(opts: {
346346
clipboardData: DataTransfer | null
347+
workspaceId: string
347348
rows: TableRowType[]
348349
complete: boolean
349350
buildCells: (row: TableRowType) => string[]
@@ -364,7 +365,7 @@ function writeLoadedRowsWithChip(opts: {
364365
'text/plain',
365366
rows.map((row) => opts.buildCells(row).join('\t')).join('\n')
366367
)
367-
attachSelectionContextToClipboard(opts.clipboardData, context)
368+
attachSelectionContextToClipboard(opts.clipboardData, context, opts.workspaceId)
368369
toast.success(`Copied ${rows.length} ${rows.length === 1 ? 'row' : 'rows'}`)
369370
return true
370371
}
@@ -3402,6 +3403,7 @@ export function TableGrid({
34023403
const selectedRows = currentRows.filter((row) => rowSelectionIncludes(rowSel, row.id))
34033404
const handled = writeLoadedRowsWithChip({
34043405
clipboardData: e.clipboardData,
3406+
workspaceId,
34053407
rows: selectedRows,
34063408
complete: true,
34073409
buildCells: (row) => cols.map((col) => cellToText(row.data[col.key], col)),
@@ -3449,6 +3451,7 @@ export function TableGrid({
34493451
// in the rest — so the chip path applies only once all of them are here.
34503452
const handled = writeLoadedRowsWithChip({
34513453
clipboardData: e.clipboardData,
3454+
workspaceId,
34523455
rows: currentRows,
34533456
complete: currentRows.length >= selectAllTotalRef.current,
34543457
buildCells: (row) =>
@@ -3486,7 +3489,9 @@ export function TableGrid({
34863489
rowIds: rangeRowIds,
34873490
columnIds: selectedColumnIds(cols, sel),
34883491
})
3489-
if (rangeContext) attachSelectionContextToClipboard(e.clipboardData, rangeContext)
3492+
if (rangeContext) {
3493+
attachSelectionContextToClipboard(e.clipboardData, rangeContext, workspaceId)
3494+
}
34903495

34913496
const lines: string[] = []
34923497
for (let r = sel.startRow; r <= sel.endRow; r++) {

0 commit comments

Comments
 (0)