From ea62de6b19a7c6186f321d42e2e7e7438bf0045e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 21:52:13 -0700 Subject: [PATCH 1/5] fix(ui): close paste admission edge cases --- .../app/_shell/paste-admission-guard.test.tsx | 41 ++++++- apps/sim/app/_shell/paste-admission-guard.tsx | 41 +++++-- .../paste-admission.test.ts | 73 +++++++++++- .../rich-markdown-editor/paste-admission.ts | 109 +++++++++++++----- .../rich-markdown-editor.tsx | 1 + .../rich-markdown-field.tsx | 19 ++- .../file-viewer/text-editor-paste.test.ts | 32 +++++ .../file-viewer/text-editor-paste.ts | 20 ++++ .../components/file-viewer/text-editor.tsx | 32 ++++- .../prompt-editor/prompt-editor.test.tsx | 30 ++++- .../prompt-editor/prompt-editor.tsx | 6 +- packages/utils/src/paste.test.ts | 4 +- packages/utils/src/paste.ts | 4 +- 13 files changed, 355 insertions(+), 57 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx index 79f9a6298a4..3d05b4711b7 100644 --- a/apps/sim/app/_shell/paste-admission-guard.test.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx @@ -17,7 +17,11 @@ import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' let host: HTMLDivElement let root: Root -function dispatchPaste(target: Element, text: string, selectionContext?: string): Event { +function dispatchPaste( + target: Element, + text: string, + options: { selectionContext?: string; html?: string } = {} +): Event { const event = new Event('paste', { bubbles: true, cancelable: true, @@ -27,7 +31,8 @@ function dispatchPaste(target: Element, text: string, selectionContext?: string) value: { getData: (type: string) => { if (type === 'text/plain') return text - if (type === SIM_SELECTION_MIME) return selectionContext ?? '' + if (type === SIM_SELECTION_MIME) return options.selectionContext ?? '' + if (type === 'text/html') return options.html ?? '' return '' }, }, @@ -93,9 +98,10 @@ describe('PasteAdmissionGuard', () => { expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false) }) - it('lets a compact Sim selection reference bypass its large plain-text representation', () => { + it('lets a prompt consume a compact Sim selection reference before its large plain text', () => { const input = document.createElement('textarea') input.dataset.pasteMaxBytes = '4' + input.dataset.pasteSelectionContext = 'reference' host.appendChild(input) const selectionContext = JSON.stringify({ kind: 'table_selection', @@ -105,6 +111,33 @@ describe('PasteAdmissionGuard', () => { label: 'Large table (1 row)', }) - expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false) + expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false) + }) + + it('still bounds a Sim selection plain-text representation outside the prompt', () => { + const input = document.createElement('textarea') + input.dataset.pasteMaxBytes = '4' + host.appendChild(input) + const selectionContext = JSON.stringify({ + kind: 'table_selection', + tableId: 'table-1', + tableName: 'Large table', + rowIds: ['row-1'], + label: 'Large table (1 row)', + }) + + expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(true) + }) + + it('bounds rich HTML separately from its smaller plain-text representation', () => { + const editable = document.createElement('div') + editable.setAttribute('contenteditable', 'true') + editable.dataset.pasteMaxBytes = '100' + editable.dataset.pasteMaxHtmlBytes = '10' + host.appendChild(editable) + + expect(dispatchPaste(editable, 'abc', { html: 'abc' }).defaultPrevented).toBe( + true + ) }) }) diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx index 6c8a30e07c6..2621364a02a 100644 --- a/apps/sim/app/_shell/paste-admission-guard.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -33,11 +33,10 @@ export function PasteAdmissionGuard() { return } - if (readSelectionContextFromClipboard(event.clipboardData)) return + const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]') + if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return const text = event.clipboardData?.getData('text/plain') ?? '' - if (!text) return - const policyElement = event.target.closest('[data-paste-max-bytes]') const maxPastedBytes = finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES @@ -45,19 +44,37 @@ export function PasteAdmissionGuard() { policyElement, 'data-paste-max-characters' ) - const admission = assessTextPaste({ - pastedText: text, - maxPastedBytes, - maxPastedCharacters, - }) - if (admission.accepted) return + const textAdmission = text + ? assessTextPaste({ + pastedText: text, + maxPastedBytes, + maxPastedCharacters, + }) + : null + const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]') + const maxPastedHtmlBytes = finitePositiveAttribute( + htmlPolicyElement, + 'data-paste-max-html-bytes' + ) + const html = maxPastedHtmlBytes ? (event.clipboardData?.getData('text/html') ?? '') : '' + const htmlAdmission = + html && maxPastedHtmlBytes + ? assessTextPaste({ pastedText: html, maxPastedBytes: maxPastedHtmlBytes }) + : null + const rejection = + textAdmission && !textAdmission.accepted + ? textAdmission + : htmlAdmission && !htmlAdmission.accepted + ? htmlAdmission + : null + if (!rejection) return event.preventDefault() event.stopImmediatePropagation() const limit = - admission.reason === 'pasted-characters' - ? `${admission.limit.toLocaleString()} characters` - : formatPasteLimit(admission.limit) + rejection.reason === 'pasted-characters' + ? `${rejection.limit.toLocaleString()} characters` + : formatPasteLimit(rejection.limit) notifyRef.current.warning('Paste is too large for this editor', { description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts index 68c6875b12d..d9f23890ee2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts @@ -5,7 +5,7 @@ import { Editor } from '@tiptap/core' import { TextSelection } from '@tiptap/pm/state' import { afterEach, describe, expect, it, vi } from 'vitest' import { createMarkdownContentExtensions } from './extensions' -import { createRichMarkdownPasteAdmission } from './paste-admission' +import { assessRawMarkdownPaste, createRichMarkdownPasteAdmission } from './paste-admission' let editor: Editor | null = null @@ -14,10 +14,16 @@ afterEach(() => { editor = null }) -function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } { +function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prevented: boolean } { let prevented = false const event = { - clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') }, + clipboardData: { + getData: (type: string) => { + if (type === 'text/plain') return text + if (type === 'text/html') return html + return '' + }, + }, preventDefault: () => { prevented = true }, @@ -31,6 +37,20 @@ function runPaste(ed: Editor, text: string): { handled: boolean; prevented: bool } describe('rich Markdown paste admission', () => { + it('rejects a raw-text append whose projected result exceeds the limit', () => { + expect( + assessRawMarkdownPaste( + { + pastedText: '56789', + currentText: '123456', + selectionStart: 6, + selectionEnd: 6, + }, + 10 + ) + ).toEqual({ accepted: false, reason: 'result-bytes', actual: 11, limit: 10 }) + }) + it('rejects before downstream paste parsing when projected bytes exceed the document limit', () => { const onRejected = vi.fn() editor = new Editor({ @@ -88,4 +108,51 @@ describe('rich Markdown paste admission', () => { expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false }) }) + + it('rejects oversized rich HTML before downstream parsing', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '', + onRejected, + }), + ], + content: '

', + }) + + expect(runPaste(editor, 'x', 'abc')).toEqual({ + handled: true, + prevented: true, + }) + expect(onRejected).toHaveBeenCalledOnce() + }) + + it('rejects a paste whose canonical Markdown result exceeds the limit', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '123456', + onRejected, + }), + ], + content: '

123456

', + }) + const strong = editor.schema.marks.bold.create() + const transaction = editor.state.tr + .replaceSelectionWith(editor.schema.text('abc', [strong]), false) + .setMeta('uiEvent', 'paste') + + expect(editor.markdown.serialize(transaction.doc.toJSON())).toBe('**abc**123456') + expect(transaction.getMeta('uiEvent')).toBe('paste') + editor.view.dispatch(transaction) + + expect(editor.getText()).toBe('123456') + expect(onRejected).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts index b5d505c6ebc..106dce3a6b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts @@ -1,6 +1,12 @@ -import { utf8ByteLength } from '@sim/utils/paste' +import { + assessTextPaste, + PASTE_LIMITS, + type TextPasteAdmission, + utf8ByteLength, +} from '@sim/utils/paste' import { Extension } from '@tiptap/core' import { Plugin } from '@tiptap/pm/state' +import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' export interface RichMarkdownPasteAdmissionOptions { maxResultBytes: number @@ -8,10 +14,26 @@ export interface RichMarkdownPasteAdmissionOptions { onRejected: () => void } +interface RawMarkdownPasteInput { + pastedText: string + currentText: string + selectionStart: number + selectionEnd: number +} + +/** Applies the rich-document boundary to a projected raw-text paste result. */ +export function assessRawMarkdownPaste( + input: RawMarkdownPasteInput, + maxResultBytes = PASTE_LIMITS.RICH_MARKDOWN_BYTES +): TextPasteAdmission { + return assessTextPaste({ ...input, maxResultBytes }) +} + /** - * Rejects a paste before Markdown parsing when its projected document would leave the editor's - * supported collaboration envelope. The selected ProseMirror text is subtracted from the current - * Markdown size, so replacing a large selection is admitted instead of being treated as an append. + * Rejects oversized clipboard representations before parsing, then filters the exact canonical + * Markdown transaction before it can leave the editor's supported collaboration envelope. The early + * plain-text projection subtracts the selected content, so a large replacement remains fast and is + * not treated as an append. */ export function createRichMarkdownPasteAdmission({ maxResultBytes, @@ -23,34 +45,69 @@ export function createRichMarkdownPasteAdmission({ priority: 1_000, addProseMirrorPlugins() { + const { editor } = this + let pasteInProgress = false + return [ new Plugin({ + filterTransaction: (transaction) => { + const isPaste = pasteInProgress || transaction.getMeta('uiEvent') === 'paste' + if (!isPaste || !transaction.docChanged) return true + pasteInProgress = false + + if (!editor.markdown) { + throw new Error('Rich Markdown paste admission requires the Markdown extension') + } + const projectedMarkdown = postProcessSerializedMarkdown( + editor.markdown.serialize(transaction.doc.toJSON()) + ) + if (utf8ByteLength(projectedMarkdown, maxResultBytes) <= maxResultBytes) return true + + onRejected() + return false + }, props: { handleDOMEvents: { paste: (view, event) => { const pastedText = event.clipboardData?.getData('text/plain') ?? '' - if (!pastedText) return false - - const currentText = getCurrentText() - const { from, to } = view.state.selection - const replacedText = view.state.doc.textBetween(from, to, '\n') - const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1 - const projectedCharacters = replacesWholeDocument - ? pastedText.length - : Math.max(0, currentText.length - replacedText.length) + pastedText.length - if (projectedCharacters <= Math.floor(maxResultBytes / 3)) return false - - const currentBytes = utf8ByteLength(currentText, maxResultBytes) - const pastedBytes = utf8ByteLength(pastedText, maxResultBytes) - const replacedBytes = replacesWholeDocument - ? currentBytes - : utf8ByteLength(replacedText, maxResultBytes) - const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes - if (projectedBytes <= maxResultBytes) return false - - event.preventDefault() - onRejected() - return true + const pastedHtml = event.clipboardData?.getData('text/html') ?? '' + if (!pastedText && !pastedHtml) return false + + if (pastedHtml && utf8ByteLength(pastedHtml, maxResultBytes) > maxResultBytes) { + event.preventDefault() + onRejected() + return true + } + + if (pastedText) { + const currentText = getCurrentText() + const { from, to } = view.state.selection + const replacedText = view.state.doc.textBetween(from, to, '\n') + const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1 + const projectedCharacters = replacesWholeDocument + ? pastedText.length + : Math.max(0, currentText.length - replacedText.length) + pastedText.length + + if (projectedCharacters > Math.floor(maxResultBytes / 3)) { + const currentBytes = utf8ByteLength(currentText, maxResultBytes) + const pastedBytes = utf8ByteLength(pastedText, maxResultBytes) + const replacedBytes = replacesWholeDocument + ? currentBytes + : utf8ByteLength(replacedText, maxResultBytes) + const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes + if (projectedBytes > maxResultBytes) { + event.preventDefault() + onRejected() + return true + } + } + } + + pasteInProgress = true + queueMicrotask(() => { + pasteInProgress = false + }) + return false }, }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 0fb024f79e4..2628bba4cde 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -556,6 +556,7 @@ export function LoadedRichMarkdownEditor({ class: 'rich-markdown-nodes rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 595c8f4b3f0..fff90d04740 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -5,6 +5,7 @@ import { ChipTextarea, chipFieldSurfaceClass, cn, toast } from '@sim/emcn' import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' +import { assessRawMarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' import { createMarkdownEditorExtensions } from './editor-extensions' import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, isInlineRouteSrc, shouldSkipFileUpload } from './image-paste' @@ -248,6 +249,7 @@ function LoadedRichMarkdownField({ // Claim ⌘K so the bubble-menu link editor wins over the global search palette. 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), }, handlePaste: (view, event) => { const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] @@ -476,7 +478,22 @@ function RawMarkdownField({ const handlePaste = (event: React.ClipboardEvent) => { const text = event.clipboardData.getData('text/plain') - if (text && onPasteText?.(text)) event.preventDefault() + if (!text) return + if (onPasteText?.(text)) { + event.preventDefault() + return + } + + const admission = assessRawMarkdownPaste({ + pastedText: text, + currentText: value, + selectionStart: event.currentTarget.selectionStart, + selectionEnd: event.currentTarget.selectionEnd, + }) + if (admission.accepted) return + + event.preventDefault() + warnRichMarkdownPasteLimit() } /* A bare host paints its own surface, so the raw fallback is a plain diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts new file mode 100644 index 00000000000..5ebfcf4dade --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { assessTextEditorPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste' + +describe('assessTextEditorPaste', () => { + it('rejects an append that would exceed the saved file boundary', () => { + expect( + assessTextEditorPaste( + { + pastedText: '56789', + currentText: '123456', + selectionStart: 6, + selectionEnd: 6, + }, + 10 + ) + ).toEqual({ accepted: false, reason: 'result-bytes', actual: 11, limit: 10 }) + }) + + it('admits replacing a selection at the boundary', () => { + expect( + assessTextEditorPaste( + { + pastedText: '56789', + currentText: '123456', + selectionStart: 1, + selectionEnd: 6, + }, + 6 + ) + ).toMatchObject({ accepted: true, resultBytes: 6 }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts new file mode 100644 index 00000000000..1f784896c3b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts @@ -0,0 +1,20 @@ +import { assessTextPaste, PASTE_LIMITS, type TextPasteAdmission } from '@sim/utils/paste' + +interface TextEditorPasteInput { + pastedText: string + currentText: string + selectionStart: number + selectionEnd: number +} + +/** Applies the workspace-file content contract to a projected Monaco paste result. */ +export function assessTextEditorPaste( + input: TextEditorPasteInput, + maxBytes = PASTE_LIMITS.TEXT_EDITOR_BYTES +): TextPasteAdmission { + return assessTextPaste({ + ...input, + maxPastedBytes: maxBytes, + maxResultBytes: maxBytes, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 963f60b077a..262365bdbb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -10,7 +10,7 @@ import { } from 'react' import type { OnMount } from '@monaco-editor/react' import { cn, toast } from '@sim/emcn' -import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' +import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { editor as MonacoEditorTypes } from 'monaco-editor' import dynamic from 'next/dynamic' import { @@ -20,6 +20,7 @@ import { import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { assessTextEditorPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste' import { useAddToChat } from '@/hooks/use-add-to-chat' import type { ChatContext } from '@/stores/panel' import { EditorContextMenu } from './editor-context-menu' @@ -586,7 +587,9 @@ export const TextEditor = memo(function TextEditor({ const handleEditorChange = useCallback( (value: string | undefined) => { - setDraftContent(value ?? '') + const nextValue = value ?? '' + contentRef.current = nextValue + setDraftContent(nextValue) }, [setDraftContent] ) @@ -595,9 +598,30 @@ export const TextEditor = memo(function TextEditor({ const pastedText = event.clipboardData.getData('text/plain') if (!pastedText) return - const admission = assessTextPaste({ + const editor = monacoEditorRef.current + const model = editor?.getModel() + const selection = editor?.getSelection() + const currentText = contentRef.current + const selectionStart = + model && selection + ? model.getOffsetAt({ + lineNumber: selection.startLineNumber, + column: selection.startColumn, + }) + : currentText.length + const selectionEnd = + model && selection + ? model.getOffsetAt({ + lineNumber: selection.endLineNumber, + column: selection.endColumn, + }) + : selectionStart + + const admission = assessTextEditorPaste({ pastedText, - maxPastedBytes: PASTE_LIMITS.TEXT_EDITOR_BYTES, + currentText, + selectionStart, + selectionEnd, }) if (admission.accepted) return diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx index 0a69d460d47..63aa2852877 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,6 +22,8 @@ vi.mock( import { PromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor' import { usePromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { ChatContext } from '@/stores/panel' /** * jsdom performs no layout, so the autosize inputs are stubbed: `editorWidth` @@ -85,14 +88,22 @@ class FakeResizeObserver implements ResizeObserver { } } -function mountEditor() { +interface MountEditorOptions { + initialValue?: string + initialContexts?: ChatContext[] +} + +function mountEditor({ + initialValue = 'a long prompt', + initialContexts = [], +}: MountEditorOptions = {}) { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') document.body.appendChild(container) const root: Root = createRoot(container) function Probe() { - const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue: 'a long prompt' }) + const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue, initialContexts }) return } @@ -237,4 +248,19 @@ describe('PromptEditor autosize', () => { expect(FakeResizeObserver.observerCount()).toBe(0) }) + + it('keeps the chip overlay for a large prompt containing a skill trigger', () => { + const context = { + kind: 'skill', + skillId: 'skill-1', + label: 'summarize', + } satisfies ChatContext + const initialValue = `${'x'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS)} ${SKILL_CHIP_TRIGGER}summarize` + const { textarea, unmount } = mountEditor({ initialValue, initialContexts: [context] }) + + expect(textarea.className).not.toContain('!text-[var(--text-primary)]') + expect(textarea.parentElement?.querySelector('[aria-hidden="true"]')).not.toBeNull() + + unmount() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index 426f4a0389e..a78c295c051 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -18,6 +18,7 @@ import { SkillsMenuDropdown } from '@/app/workspace/[workspaceId]/home/component import { computeMentionHighlightRanges, extractContextTokens, + SKILL_CHIP_TRIGGER, stripMentionTrigger, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -79,7 +80,9 @@ export function PromptEditor({ * Un-warming on blur would just re-open the race on the next focus. */ const [hasFocused, setHasFocused] = useState(false) - const usePlainTextMode = value.length > PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS + const usePlainTextMode = + value.length > PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS && + !value.includes(SKILL_CHIP_TRIGGER) /** * Autosize: grow the textarea to its full content height; the scroller caps @@ -250,6 +253,7 @@ export function PromptEditor({ onPaste={readOnly ? undefined : editor.handlePaste} data-paste-max-bytes={PASTE_LIMITS.CHAT_BYTES} data-paste-max-characters={PASTE_LIMITS.CHAT_CHARACTERS} + data-paste-selection-context='reference' onCopy={editor.handleCopy} onCut={readOnly ? undefined : editor.handleCut} onSelect={readOnly ? undefined : editor.handleSelectAdjust} diff --git a/packages/utils/src/paste.test.ts b/packages/utils/src/paste.test.ts index 50418695f57..c010513a3c3 100644 --- a/packages/utils/src/paste.test.ts +++ b/packages/utils/src/paste.test.ts @@ -88,9 +88,9 @@ it('formats binary paste limits', () => { expect(formatPasteLimit(5 * 1024 * 1024)).toBe('5 MiB') }) -it('keeps non-contract paste ceilings in crash-only territory', () => { +it('keeps crash-only ceilings high and aligns file editing with its content contract', () => { expect(PASTE_LIMITS.DEFAULT_BYTES).toBe(32 * 1024 * 1024) - expect(PASTE_LIMITS.TEXT_EDITOR_BYTES).toBe(32 * 1024 * 1024) + expect(PASTE_LIMITS.TEXT_EDITOR_BYTES).toBe(50 * 1024 * 1024) expect(PASTE_LIMITS.TERMINAL_BYTES).toBe(8 * 1024 * 1024) expect(PASTE_LIMITS.STRUCTURED_BYTES).toBe(32 * 1024 * 1024) }) diff --git a/packages/utils/src/paste.ts b/packages/utils/src/paste.ts index 1a4ecdf1f53..b2d38d4bd07 100644 --- a/packages/utils/src/paste.ts +++ b/packages/utils/src/paste.ts @@ -3,8 +3,8 @@ export const PASTE_LIMITS = { DEFAULT_BYTES: 32 * 1024 * 1024, /** Matches the existing collaborative-document seed boundary. */ RICH_MARKDOWN_BYTES: 5 * 1024 * 1024, - /** Stays below the 50 MiB JSON request boundary while allowing genuinely large source files. */ - TEXT_EDITOR_BYTES: 32 * 1024 * 1024, + /** Matches the inline workspace-file content boundary. */ + TEXT_EDITOR_BYTES: 50 * 1024 * 1024, /** Matches the deployed chat request contract. */ CHAT_CHARACTERS: 1_000_000, /** A Unicode scalar can occupy at most four UTF-8 bytes. */ From 9813193cfc73087b04725f79596b75ea0d735b0e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 22:00:19 -0700 Subject: [PATCH 2/5] fix(ui): account for multi-cursor pastes --- .../paste-admission.test.ts | 7 +- .../file-viewer/text-editor-paste.test.ts | 22 +++- .../file-viewer/text-editor-paste.ts | 106 ++++++++++++++++-- .../components/file-viewer/text-editor.tsx | 32 +++--- 4 files changed, 135 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts index d9f23890ee2..a7b73083704 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts @@ -4,8 +4,11 @@ import { Editor } from '@tiptap/core' import { TextSelection } from '@tiptap/pm/state' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownContentExtensions } from './extensions' -import { assessRawMarkdownPaste, createRichMarkdownPasteAdmission } from './paste-admission' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + assessRawMarkdownPaste, + createRichMarkdownPasteAdmission, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' let editor: Editor | null = null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts index 5ebfcf4dade..88b22624bf7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts @@ -8,8 +8,7 @@ describe('assessTextEditorPaste', () => { { pastedText: '56789', currentText: '123456', - selectionStart: 6, - selectionEnd: 6, + selections: [{ start: 6, end: 6 }], }, 10 ) @@ -22,11 +21,26 @@ describe('assessTextEditorPaste', () => { { pastedText: '56789', currentText: '123456', - selectionStart: 1, - selectionEnd: 6, + selections: [{ start: 1, end: 6 }], }, 6 ) ).toMatchObject({ accepted: true, resultBytes: 6 }) }) + + it('projects the clipboard text at every Monaco cursor', () => { + expect( + assessTextEditorPaste( + { + pastedText: 'xy', + currentText: '12345678', + selections: [ + { start: 2, end: 2 }, + { start: 6, end: 6 }, + ], + }, + 10 + ) + ).toMatchObject({ accepted: false, reason: 'result-bytes', limit: 10 }) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts index 1f784896c3b..7bc66f69e7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts @@ -1,20 +1,108 @@ -import { assessTextPaste, PASTE_LIMITS, type TextPasteAdmission } from '@sim/utils/paste' +import { + PASTE_LIMITS, + type TextPasteAdmission, + utf8ByteLength, + utf8ByteLengthRange, +} from '@sim/utils/paste' + +interface TextEditorPasteSelection { + start: number + end: number +} interface TextEditorPasteInput { pastedText: string currentText: string - selectionStart: number - selectionEnd: number + selections: readonly TextEditorPasteSelection[] +} + +function normalizedSelections( + selections: readonly TextEditorPasteSelection[], + textLength: number +): TextEditorPasteSelection[] { + const source = selections.length > 0 ? selections : [{ start: textLength, end: textLength }] + return source + .map(({ start, end }) => ({ + start: Math.min(Math.max(Math.min(start, end), 0), textLength), + end: Math.min(Math.max(Math.max(start, end), 0), textLength), + })) + .sort((left, right) => left.start - right.start || left.end - right.end) +} + +function mergedReplacementRanges( + selections: readonly TextEditorPasteSelection[] +): TextEditorPasteSelection[] { + const ranges: TextEditorPasteSelection[] = [] + for (const selection of selections) { + if (selection.start === selection.end) continue + const previous = ranges.at(-1) + if (previous && selection.start <= previous.end) { + previous.end = Math.max(previous.end, selection.end) + } else { + ranges.push({ ...selection }) + } + } + return ranges } -/** Applies the workspace-file content contract to a projected Monaco paste result. */ +/** Applies the workspace-file content contract to every selection in a projected Monaco paste. */ export function assessTextEditorPaste( input: TextEditorPasteInput, maxBytes = PASTE_LIMITS.TEXT_EDITOR_BYTES ): TextPasteAdmission { - return assessTextPaste({ - ...input, - maxPastedBytes: maxBytes, - maxResultBytes: maxBytes, - }) + const selections = normalizedSelections(input.selections, input.currentText.length) + const replacementRanges = mergedReplacementRanges(selections) + const replacedCharacters = replacementRanges.reduce( + (total, selection) => total + selection.end - selection.start, + 0 + ) + const resultCharacters = + input.currentText.length - replacedCharacters + input.pastedText.length * selections.length + + if (resultCharacters <= Math.floor(maxBytes / 3)) { + return { accepted: true, resultCharacters } + } + + const pastedBytes = utf8ByteLength(input.pastedText, maxBytes) + if (pastedBytes > maxBytes) { + return { accepted: false, reason: 'pasted-bytes', actual: pastedBytes, limit: maxBytes } + } + + const insertedBytes = pastedBytes * selections.length + if (insertedBytes > maxBytes) { + return { accepted: false, reason: 'result-bytes', actual: insertedBytes, limit: maxBytes } + } + + let retainedBytes = 0 + let retainedStart = 0 + for (const selection of replacementRanges) { + retainedBytes += utf8ByteLengthRange( + input.currentText, + retainedStart, + selection.start, + maxBytes - insertedBytes - retainedBytes + ) + if (retainedBytes + insertedBytes > maxBytes) { + return { + accepted: false, + reason: 'result-bytes', + actual: retainedBytes + insertedBytes, + limit: maxBytes, + } + } + retainedStart = selection.end + } + retainedBytes += utf8ByteLengthRange( + input.currentText, + retainedStart, + input.currentText.length, + maxBytes - insertedBytes - retainedBytes + ) + + const resultBytes = retainedBytes + insertedBytes + if (resultBytes > maxBytes) { + return { accepted: false, reason: 'result-bytes', actual: resultBytes, limit: maxBytes } + } + + return { accepted: true, pastedBytes, resultBytes, resultCharacters } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 262365bdbb8..d1525e62f0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -600,28 +600,26 @@ export const TextEditor = memo(function TextEditor({ const editor = monacoEditorRef.current const model = editor?.getModel() - const selection = editor?.getSelection() + const selections = editor?.getSelections() const currentText = contentRef.current - const selectionStart = - model && selection - ? model.getOffsetAt({ - lineNumber: selection.startLineNumber, - column: selection.startColumn, - }) - : currentText.length - const selectionEnd = - model && selection - ? model.getOffsetAt({ - lineNumber: selection.endLineNumber, - column: selection.endColumn, - }) - : selectionStart + const selectionOffsets = + model && selections?.length + ? selections.map((selection) => ({ + start: model.getOffsetAt({ + lineNumber: selection.startLineNumber, + column: selection.startColumn, + }), + end: model.getOffsetAt({ + lineNumber: selection.endLineNumber, + column: selection.endColumn, + }), + })) + : [{ start: currentText.length, end: currentText.length }] const admission = assessTextEditorPaste({ pastedText, currentText, - selectionStart, - selectionEnd, + selections: selectionOffsets, }) if (admission.accepted) return From 0dd58509ebb9e666bc7490bf0698e15e4506e716 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 22:03:07 -0700 Subject: [PATCH 3/5] fix(ui): preserve rich image pastes --- .../app/_shell/paste-admission-guard.test.tsx | 23 ++++++++++++++++++- apps/sim/app/_shell/paste-admission-guard.tsx | 12 ++++++++++ .../rich-markdown-editor.tsx | 1 + .../rich-markdown-field.tsx | 1 + 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx index 3d05b4711b7..6e87f5e6160 100644 --- a/apps/sim/app/_shell/paste-admission-guard.test.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx @@ -20,7 +20,7 @@ let root: Root function dispatchPaste( target: Element, text: string, - options: { selectionContext?: string; html?: string } = {} + options: { selectionContext?: string; html?: string; imageFile?: boolean } = {} ): Event { const event = new Event('paste', { bubbles: true, @@ -35,6 +35,8 @@ function dispatchPaste( if (type === 'text/html') return options.html ?? '' return '' }, + files: options.imageFile ? [new File(['image'], 'pasted.png', { type: 'image/png' })] : [], + items: options.imageFile ? [{ kind: 'file', type: 'image/png' }] : [], }, }) target.dispatchEvent(event) @@ -140,4 +142,23 @@ describe('PasteAdmissionGuard', () => { true ) }) + + it('lets an opted-in rich editor handle clipboard image files before text admission', () => { + const editable = document.createElement('div') + editable.setAttribute('contenteditable', 'true') + editable.dataset.pasteMaxBytes = '4' + editable.dataset.pasteMaxHtmlBytes = '4' + editable.dataset.pasteHandlesImages = 'true' + host.appendChild(editable) + + const targetHandler = vi.fn() + editable.addEventListener('paste', targetHandler) + const event = dispatchPaste(editable, '12345', { + html: '', + imageFile: true, + }) + + expect(event.defaultPrevented).toBe(false) + expect(targetHandler).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx index 2621364a02a..db4328e8270 100644 --- a/apps/sim/app/_shell/paste-admission-guard.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -15,11 +15,20 @@ function finitePositiveAttribute(element: Element | null, name: string): number return Number.isFinite(value) && value > 0 ? value : undefined } +function clipboardHasImageFile(data: DataTransfer | null): boolean { + if (!data) return false + if (Array.from(data.files).some((file) => file.type.startsWith('image/'))) return true + return Array.from(data.items).some( + (item) => item.kind === 'file' && item.type.startsWith('image/') + ) +} + /** * Last-resort admission for every editable workspace surface. Specialized editors publish their * downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a * crash-only fallback. This layer bounds only the clipboard payload, so a small paste into an already * large field keeps native behavior. Editors with a real result-size contract enforce it themselves. + * Targets that explicitly handle clipboard images may claim those file events before the text guards. * The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value. */ export function PasteAdmissionGuard() { @@ -36,6 +45,9 @@ export function PasteAdmissionGuard() { const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]') if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return + const handlesImageFiles = event.target.closest('[data-paste-handles-images="true"]') + if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return + const text = event.clipboardData?.getData('text/plain') ?? '' const policyElement = event.target.closest('[data-paste-max-bytes]') const maxPastedBytes = diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 2628bba4cde..9684f1bab84 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -557,6 +557,7 @@ export function LoadedRichMarkdownEditor({ 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-handles-images': 'true', }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index fff90d04740..7005203bd0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -250,6 +250,7 @@ function LoadedRichMarkdownField({ 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-handles-images': uploadImage ? 'true' : 'false', }, handlePaste: (view, event) => { const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] From 71447c47ee5c3e76d2d09475e4fa25fe1e52bfa6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 22:09:31 -0700 Subject: [PATCH 4/5] fix(ui): match distributed multi-cursor paste --- .../file-viewer/text-editor-paste.test.ts | 33 ++++++++++++ .../file-viewer/text-editor-paste.ts | 52 +++++++++++++++++-- .../components/file-viewer/text-editor.tsx | 1 + 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts index 88b22624bf7..c8bda85e246 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts @@ -43,4 +43,37 @@ describe('assessTextEditorPaste', () => { ) ).toMatchObject({ accepted: false, reason: 'result-bytes', limit: 10 }) }) + + it('projects one matching clipboard line per cursor in Monaco spread mode', () => { + expect( + assessTextEditorPaste( + { + pastedText: 'x\ny\n', + currentText: '12345678', + selections: [ + { start: 2, end: 2 }, + { start: 6, end: 6 }, + ], + }, + 10 + ) + ).toMatchObject({ accepted: true, resultBytes: 10 }) + }) + + it('projects the full clipboard at every cursor when Monaco spread mode is disabled', () => { + expect( + assessTextEditorPaste( + { + pastedText: 'x\ny', + currentText: '123456', + selections: [ + { start: 2, end: 2 }, + { start: 4, end: 4 }, + ], + multiCursorPaste: 'full', + }, + 10 + ) + ).toMatchObject({ accepted: false, reason: 'result-bytes', limit: 10 }) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts index 7bc66f69e7d..be35d20bd59 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts @@ -14,6 +14,7 @@ interface TextEditorPasteInput { pastedText: string currentText: string selections: readonly TextEditorPasteSelection[] + multiCursorPaste?: 'spread' | 'full' } function normalizedSelections( @@ -45,6 +46,30 @@ function mergedReplacementRanges( return ranges } +function distributedPasteRanges( + text: string, + selectionCount: number +): TextEditorPasteSelection[] | null { + if (selectionCount <= 1) return null + + let textEnd = text.length + if (text.charCodeAt(textEnd - 1) === 10) textEnd -= 1 + if (text.charCodeAt(textEnd - 1) === 13) textEnd -= 1 + + const ranges: TextEditorPasteSelection[] = [] + let lineStart = 0 + for (let index = 0; index < textEnd; index++) { + const code = text.charCodeAt(index) + if (code !== 10 && code !== 13) continue + ranges.push({ start: lineStart, end: index }) + if (ranges.length >= selectionCount) return null + if (code === 13 && text.charCodeAt(index + 1) === 10) index += 1 + lineStart = index + 1 + } + ranges.push({ start: lineStart, end: textEnd }) + return ranges.length === selectionCount ? ranges : null +} + /** Applies the workspace-file content contract to every selection in a projected Monaco paste. */ export function assessTextEditorPaste( input: TextEditorPasteInput, @@ -52,12 +77,27 @@ export function assessTextEditorPaste( ): TextPasteAdmission { const selections = normalizedSelections(input.selections, input.currentText.length) const replacementRanges = mergedReplacementRanges(selections) + const distributedRanges = + (input.multiCursorPaste ?? 'spread') === 'spread' + ? distributedPasteRanges(input.pastedText, selections.length) + : null const replacedCharacters = replacementRanges.reduce( (total, selection) => total + selection.end - selection.start, 0 ) - const resultCharacters = - input.currentText.length - replacedCharacters + input.pastedText.length * selections.length + const insertedCharacters = distributedRanges + ? distributedRanges.reduce((total, range) => total + range.end - range.start, 0) + : input.pastedText.length * selections.length + const resultCharacters = input.currentText.length - replacedCharacters + insertedCharacters + + if (input.pastedText.length > maxBytes) { + return { + accepted: false, + reason: 'pasted-bytes', + actual: input.pastedText.length, + limit: maxBytes, + } + } if (resultCharacters <= Math.floor(maxBytes / 3)) { return { accepted: true, resultCharacters } @@ -68,7 +108,13 @@ export function assessTextEditorPaste( return { accepted: false, reason: 'pasted-bytes', actual: pastedBytes, limit: maxBytes } } - const insertedBytes = pastedBytes * selections.length + const insertedBytes = distributedRanges + ? distributedRanges.reduce( + (total, range) => + total + utf8ByteLengthRange(input.pastedText, range.start, range.end, maxBytes - total), + 0 + ) + : pastedBytes * selections.length if (insertedBytes > maxBytes) { return { accepted: false, reason: 'result-bytes', actual: insertedBytes, limit: maxBytes } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index d1525e62f0a..85e14bb7910 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -620,6 +620,7 @@ export const TextEditor = memo(function TextEditor({ pastedText, currentText, selections: selectionOffsets, + multiCursorPaste: editor?.getRawOptions().multiCursorPaste ?? 'spread', }) if (admission.accepted) return From dd9a780a50e0005a6bef5a2f6580adc50e4a07e7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 22:15:24 -0700 Subject: [PATCH 5/5] fix(ui): defer exact Monaco paste admission --- .../app/_shell/paste-admission-guard.test.tsx | 13 ++++++++++++ apps/sim/app/_shell/paste-admission-guard.tsx | 16 ++++++++------- .../file-viewer/text-editor-paste.test.ts | 20 +++++++++++++++++++ .../file-viewer/text-editor-paste.ts | 8 ++++---- .../components/file-viewer/text-editor.tsx | 1 + 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx index 6e87f5e6160..d10ad45b2c0 100644 --- a/apps/sim/app/_shell/paste-admission-guard.test.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx @@ -100,6 +100,19 @@ describe('PasteAdmissionGuard', () => { expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false) }) + it('defers text admission to an editor that projects its exact paste result', () => { + const editor = document.createElement('div') + editor.setAttribute('contenteditable', 'true') + editor.dataset.pasteMaxBytes = '4' + editor.dataset.pasteProjectsTextResult = 'true' + host.appendChild(editor) + + const targetHandler = vi.fn() + editor.addEventListener('paste', targetHandler) + expect(dispatchPaste(editor, '12345').defaultPrevented).toBe(false) + expect(targetHandler).toHaveBeenCalledOnce() + }) + it('lets a prompt consume a compact Sim selection reference before its large plain text', () => { const input = document.createElement('textarea') input.dataset.pasteMaxBytes = '4' diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx index db4328e8270..1a12cb1f3a7 100644 --- a/apps/sim/app/_shell/paste-admission-guard.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -49,6 +49,7 @@ export function PasteAdmissionGuard() { if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return const text = event.clipboardData?.getData('text/plain') ?? '' + const projectsTextResult = event.target.closest('[data-paste-projects-text-result="true"]') const policyElement = event.target.closest('[data-paste-max-bytes]') const maxPastedBytes = finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES @@ -56,13 +57,14 @@ export function PasteAdmissionGuard() { policyElement, 'data-paste-max-characters' ) - const textAdmission = text - ? assessTextPaste({ - pastedText: text, - maxPastedBytes, - maxPastedCharacters, - }) - : null + const textAdmission = + text && !projectsTextResult + ? assessTextPaste({ + pastedText: text, + maxPastedBytes, + maxPastedCharacters, + }) + : null const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]') const maxPastedHtmlBytes = finitePositiveAttribute( htmlPolicyElement, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts index c8bda85e246..287a5c74825 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts @@ -60,6 +60,26 @@ describe('assessTextEditorPaste', () => { ).toMatchObject({ accepted: true, resultBytes: 10 }) }) + it('admits a distributed result when only removed line separators exceed the boundary', () => { + expect( + assessTextEditorPaste( + { + pastedText: 'a\nb\nc\nd\ne\nf', + currentText: '1234', + selections: [ + { start: 0, end: 0 }, + { start: 1, end: 1 }, + { start: 2, end: 2 }, + { start: 3, end: 3 }, + { start: 4, end: 4 }, + { start: 4, end: 4 }, + ], + }, + 10 + ) + ).toMatchObject({ accepted: true, resultBytes: 10 }) + }) + it('projects the full clipboard at every cursor when Monaco spread mode is disabled', () => { expect( assessTextEditorPaste( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts index be35d20bd59..65250856260 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.ts @@ -90,7 +90,7 @@ export function assessTextEditorPaste( : input.pastedText.length * selections.length const resultCharacters = input.currentText.length - replacedCharacters + insertedCharacters - if (input.pastedText.length > maxBytes) { + if (!distributedRanges && input.pastedText.length > maxBytes) { return { accepted: false, reason: 'pasted-bytes', @@ -103,8 +103,8 @@ export function assessTextEditorPaste( return { accepted: true, resultCharacters } } - const pastedBytes = utf8ByteLength(input.pastedText, maxBytes) - if (pastedBytes > maxBytes) { + const pastedBytes = distributedRanges ? undefined : utf8ByteLength(input.pastedText, maxBytes) + if (pastedBytes !== undefined && pastedBytes > maxBytes) { return { accepted: false, reason: 'pasted-bytes', actual: pastedBytes, limit: maxBytes } } @@ -114,7 +114,7 @@ export function assessTextEditorPaste( total + utf8ByteLengthRange(input.pastedText, range.start, range.end, maxBytes - total), 0 ) - : pastedBytes * selections.length + : (pastedBytes ?? 0) * selections.length if (insertedBytes > maxBytes) { return { accepted: false, reason: 'result-bytes', actual: insertedBytes, limit: maxBytes } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 85e14bb7910..54667452d27 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -683,6 +683,7 @@ export const TextEditor = memo(function TextEditor({ {showEditor && (