Skip to content

Commit 580d1c0

Browse files
committed
fix(ui): preserve large context paste behavior
1 parent 9233ecb commit 580d1c0

5 files changed

Lines changed: 97 additions & 24 deletions

File tree

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,26 @@ vi.mock('@sim/emcn', () => ({
1111
useToast: () => ({ toast: { warning } }),
1212
}))
1313

14+
import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
1415
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
1516

1617
let host: HTMLDivElement
1718
let root: Root
1819

19-
function dispatchPaste(target: Element, text: string): Event {
20-
const event = new Event('paste', { bubbles: true, cancelable: true, composed: true })
20+
function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
21+
const event = new Event('paste', {
22+
bubbles: true,
23+
cancelable: true,
24+
composed: true,
25+
})
2126
Object.defineProperty(event, 'clipboardData', {
22-
value: { getData: (type: string) => (type === 'text/plain' ? text : '') },
27+
value: {
28+
getData: (type: string) => {
29+
if (type === 'text/plain') return text
30+
if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
31+
return ''
32+
},
33+
},
2334
})
2435
target.dispatchEvent(event)
2536
return event
@@ -81,4 +92,19 @@ describe('PasteAdmissionGuard', () => {
8192

8293
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
8394
})
95+
96+
it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
97+
const input = document.createElement('textarea')
98+
input.dataset.pasteMaxBytes = '4'
99+
host.appendChild(input)
100+
const selectionContext = JSON.stringify({
101+
kind: 'table_selection',
102+
tableId: 'table-1',
103+
tableName: 'Large table',
104+
rowIds: ['row-1'],
105+
label: 'Large table (1 row)',
106+
})
107+
108+
expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
109+
})
84110
})

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef } from 'react'
44
import { useToast } from '@sim/emcn'
55
import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
6+
import { readSelectionContextFromClipboard } from '@/lib/copilot/chat/selection-clipboard'
67

78
const EDITABLE_TARGET_SELECTOR =
89
'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm'
@@ -32,6 +33,8 @@ export function PasteAdmissionGuard() {
3233
return
3334
}
3435

36+
if (readSelectionContextFromClipboard(event.clipboardData)) return
37+
3538
const text = event.clipboardData?.getData('text/plain') ?? ''
3639
if (!text) return
3740

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock('@/blocks/integration-matcher', () => ({
1111
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
1212
}))
1313

14+
import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
1415
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
1516
import {
1617
type UsePromptEditorProps,
@@ -278,6 +279,42 @@ describe('usePromptEditor context insertion', () => {
278279
unmount()
279280
})
280281

282+
it('pastes a large table selection as a compact context chip', () => {
283+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
284+
callback(0)
285+
return 1
286+
})
287+
const context = {
288+
kind: 'table_selection',
289+
tableId: 'table-1',
290+
tableName: 'Large table',
291+
rowIds: ['row-1'],
292+
label: 'Large table (1 row)',
293+
} satisfies ChatContext
294+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
295+
const preventDefault = vi.fn()
296+
297+
act(() => {
298+
result().handlePaste({
299+
currentTarget: textarea,
300+
clipboardData: {
301+
getData: (type: string) => {
302+
if (type === 'text/plain') return 'x'.repeat(1_000_001)
303+
if (type === SIM_SELECTION_MIME) return JSON.stringify(context)
304+
return ''
305+
},
306+
},
307+
preventDefault,
308+
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>)
309+
})
310+
311+
expect(preventDefault).toHaveBeenCalledOnce()
312+
expect(result().value).toBe('@Large table (1 row) ')
313+
expect(result().contexts).toEqual([context])
314+
315+
unmount()
316+
})
317+
281318
it('suffixes duplicate visible labels so two browser selections coexist', () => {
282319
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
283320
callback(0)
@@ -295,7 +332,9 @@ describe('usePromptEditor context insertion', () => {
295332
label: 'Browser · Another page title',
296333
selection: { text: 'second selection', url: 'https://example.com' },
297334
} satisfies ChatContext
298-
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
335+
const { result, textarea, unmount } = renderPromptEditor({
336+
workspaceId: 'ws-1',
337+
})
299338

300339
act(() => {
301340
result().insertContext(first)

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

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -991,26 +991,6 @@ export function usePromptEditor({
991991
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
992992
const textarea = e.currentTarget
993993
const pastedPlainText = e.clipboardData?.getData('text/plain') ?? ''
994-
if (pastedPlainText) {
995-
const admission = assessTextPaste({
996-
pastedText: pastedPlainText,
997-
maxPastedBytes: PASTE_LIMITS.CHAT_BYTES,
998-
maxPastedCharacters: PASTE_LIMITS.CHAT_CHARACTERS,
999-
currentText: textarea.value,
1000-
selectionStart: textarea.selectionStart,
1001-
selectionEnd: textarea.selectionEnd,
1002-
maxResultBytes: PASTE_LIMITS.CHAT_BYTES,
1003-
maxResultCharacters: PASTE_LIMITS.CHAT_CHARACTERS,
1004-
})
1005-
if (!admission.accepted) {
1006-
e.preventDefault()
1007-
toast.warning('Paste is too large for a message', {
1008-
description: `Messages support up to ${PASTE_LIMITS.CHAT_CHARACTERS.toLocaleString()} characters. Attach the content as a file to send more without slowing the editor.`,
1009-
})
1010-
return
1011-
}
1012-
}
1013-
1014994
// A selection copied from a file/table (Cmd+C) carries its context on a
1015995
// custom clipboard type — paste it as a reference chip instead of plain text.
1016996
// Registers via `addContext` (not the notified path) so paste never opens a
@@ -1039,6 +1019,26 @@ export function usePromptEditor({
10391019
return
10401020
}
10411021

1022+
if (pastedPlainText) {
1023+
const admission = assessTextPaste({
1024+
pastedText: pastedPlainText,
1025+
maxPastedBytes: PASTE_LIMITS.CHAT_BYTES,
1026+
maxPastedCharacters: PASTE_LIMITS.CHAT_CHARACTERS,
1027+
currentText: textarea.value,
1028+
selectionStart: textarea.selectionStart,
1029+
selectionEnd: textarea.selectionEnd,
1030+
maxResultBytes: PASTE_LIMITS.CHAT_BYTES,
1031+
maxResultCharacters: PASTE_LIMITS.CHAT_CHARACTERS,
1032+
})
1033+
if (!admission.accepted) {
1034+
e.preventDefault()
1035+
toast.warning('Paste is too large for a message', {
1036+
description: `Messages support up to ${PASTE_LIMITS.CHAT_CHARACTERS.toLocaleString()} characters. Attach the content as a file to send more without slowing the editor.`,
1037+
})
1038+
return
1039+
}
1040+
}
1041+
10421042
// Portable chip links (`[label](sim:kind/id)`) re-create their chip on
10431043
// paste-back. Rewrite each link span to its `@label ` token (the trailing
10441044
// space is REQUIRED so useContextManagement's sync effect doesn't purge the

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,11 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
536536
)
537537
currentEditor.clear()
538538
sttPrefixRef.current = ''
539+
if (draftSaveTimerRef.current !== null) {
540+
window.clearTimeout(draftSaveTimerRef.current)
541+
draftSaveTimerRef.current = null
542+
}
543+
pendingDraftRef.current = null
539544
if (draftScopeKeyRef.current) {
540545
useMothershipDraftsStore.getState().clearDraft(draftScopeKeyRef.current)
541546
}

0 commit comments

Comments
 (0)