From 1ee509a4bf109d8bbaa9c808e7091a19749c8285 Mon Sep 17 00:00:00 2001 From: vanthunder Date: Mon, 10 Aug 2026 17:55:03 +0200 Subject: [PATCH] fix(app): prevent large paste stalls The V2 composer routed every paste carrying text/plain straight into execCommand("insertText"), so its large-paste guard was unreachable and the browser built one DOM node per line. Every following keystroke then had to walk that document back into prompt parts. Large pastes now apply to the prompt model directly, honouring the selection and the caret. Legacy got this in 7e681b0bc0; V2 never did. Also bounds the cursor and suggestion scans, coalesces draft writes before the IPC hop, and stops the desktop main process from reading every stored draft back at startup. 100 KiB paste: 13.9 s -> 42 ms. 256 KiB and 1 MiB did not complete before. --- .../composer/composer-paste-benchmark.spec.ts | 77 ++++++ .../composer/composer-paste-probe.ts | 157 +++++++++++ .../performance/composer/composer-session.ts | 54 ++++ .../composer-typing-benchmark.spec.ts | 42 +++ .../prompt-input/attachments.test.ts | 2 +- .../components/prompt-input/attachments.ts | 2 +- .../src/components/prompt-input/editor-dom.ts | 49 +++- .../app/src/components/prompt-input/paste.ts | 24 -- packages/app/src/utils/draft-store.ts | 56 +++- .../prompt-input-v2-paste.test.ts | 250 ++++++++++++++++++ .../test-browser/prompt-persistence.test.ts | 82 +++++- packages/desktop/src/main/draft-store.test.ts | 83 ++++++ packages/desktop/src/main/draft-store.ts | 44 +-- packages/session-ui/package.json | 2 + .../v2/components/prompt-input/attachments.ts | 33 +-- .../src/v2/components/prompt-input/editor.ts | 63 +++++ .../src/v2/components/prompt-input/index.tsx | 12 +- .../v2/components/prompt-input/interaction.ts | 52 ++-- .../src/v2/components/prompt-input/machine.ts | 25 +- .../v2/components/prompt-input/paste.test.ts | 92 +++++++ .../src/v2/components/prompt-input/paste.ts | 32 +++ .../v2/components/prompt-input/store.test.ts | 64 +++++ .../src/v2/components/prompt-input/store.ts | 73 +++-- 23 files changed, 1234 insertions(+), 136 deletions(-) create mode 100644 packages/app/e2e/performance/composer/composer-paste-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/composer/composer-paste-probe.ts create mode 100644 packages/app/e2e/performance/composer/composer-session.ts create mode 100644 packages/app/e2e/performance/composer/composer-typing-benchmark.spec.ts delete mode 100644 packages/app/src/components/prompt-input/paste.ts create mode 100644 packages/app/test-browser/prompt-input-v2-paste.test.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/editor.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/paste.test.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/paste.ts diff --git a/packages/app/e2e/performance/composer/composer-paste-benchmark.spec.ts b/packages/app/e2e/performance/composer/composer-paste-benchmark.spec.ts new file mode 100644 index 000000000000..5a802a3743bf --- /dev/null +++ b/packages/app/e2e/performance/composer/composer-paste-benchmark.spec.ts @@ -0,0 +1,77 @@ +import { benchmark, expect } from "../benchmark" +import { openComposerSession } from "./composer-session" +import { + buildFixtureText, + measureComposerPaste, + median, + percentile, + type ComposerPasteFixture, + type ComposerPasteSample, +} from "./composer-paste-probe" + +const iterations = Number(process.env.OPENCODE_PASTE_ITERATIONS ?? 5) + +const fixtures: ComposerPasteFixture[] = [ + { label: "5KiB-multiline", chars: 5 * 1024, lines: 60 }, + { label: "64KiB-multiline", chars: 64 * 1024, lines: 800 }, + { label: "100KiB-log", chars: 100 * 1024, lines: 1200 }, + { label: "256KiB-log", chars: 256 * 1024, lines: 3000 }, + { label: "1MiB-log", chars: 1024 * 1024, lines: 12000 }, + { label: "1MiB-single-line", chars: 1024 * 1024, lines: 1 }, + { label: "256KiB-crlf", chars: 256 * 1024, lines: 3000, crlf: true }, +] + +function summarize(samples: ComposerPasteSample[]) { + const settle = samples.map((sample) => sample.settleMs) + const dispatch = samples.map((sample) => sample.dispatchMs) + const blocked = samples.map((sample) => sample.blockedMs) + return { + iterations: samples.length, + chars: samples[0]?.chars ?? 0, + dispatchMedianMs: median(dispatch), + settleMedianMs: median(settle), + settleP95Ms: percentile(settle, 0.95), + settleMaxMs: Math.max(...settle), + blockedMedianMs: median(blocked), + longestTaskMaxMs: Math.max(...samples.map((sample) => sample.longestTaskMs)), + editorElementsMax: Math.max(...samples.map((sample) => sample.editorElements)), + lossless: samples.every((sample) => sample.lossless), + } +} + +// A composer that stalls the renderer never returns from its paste, so every fixture runs as +// its own test with its own page. One hung tier then costs only its own measurement instead +// of every tier queued behind it. +const FIXTURE_TIMEOUT_MS = 3 * 60_000 + +for (const composer of ["v2", "legacy"] as const) { + for (const fixture of fixtures) { + benchmark(`composer paste ${composer} ${fixture.label}`, async ({ page, report }) => { + benchmark.setTimeout(FIXTURE_TIMEOUT_MS) + const selector = await openComposerSession(page, composer === "v2") + const text = buildFixtureText(fixture) + + const samples: ComposerPasteSample[] = [] + for (let index = 0; index < iterations; index += 1) { + samples.push(await measureComposerPaste(page, selector, text)) + await page.evaluate((target) => { + const editor = document.querySelector(target) + if (!(editor instanceof HTMLElement)) return + const range = document.createRange() + range.selectNodeContents(editor) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + document.execCommand("delete") + }, selector) + await page.waitForTimeout(150) + } + + const summary = summarize(samples) + report(summary, { composer, fixture: fixture.label, chars: fixture.chars, lines: fixture.lines }) + + // Asserted after reporting so a lossy paste still leaves its measurements behind. + expect(summary.lossless, `${composer} ${fixture.label} must round-trip losslessly`).toBe(true) + }) + } +} diff --git a/packages/app/e2e/performance/composer/composer-paste-probe.ts b/packages/app/e2e/performance/composer/composer-paste-probe.ts new file mode 100644 index 000000000000..fb58aafe0a52 --- /dev/null +++ b/packages/app/e2e/performance/composer/composer-paste-probe.ts @@ -0,0 +1,157 @@ +import type { Page } from "@playwright/test" + +export type ComposerPasteFixture = { + label: string + chars: number + lines: number + crlf?: boolean +} + +export type ComposerPasteSample = { + chars: number + dispatchMs: number + settleMs: number + longTasks: number + longestTaskMs: number + blockedMs: number + editorElements: number + editorTextLength: number + lossless: boolean +} + +export function buildFixtureText(fixture: ComposerPasteFixture) { + const newline = fixture.crlf ? "\r\n" : "\n" + if (fixture.lines <= 1) + return "abcdefghij klmnopqrst uvwxyz0123 ".repeat(Math.ceil(fixture.chars / 33)).slice(0, fixture.chars) + const per = Math.max(1, Math.floor(fixture.chars / fixture.lines) - newline.length) + const body = "abcdefghij klmnopqrst uvwxyz0123 ".repeat(Math.ceil(per / 33)) + return Array.from({ length: fixture.lines }, (_, index) => `${index} ${body}`.slice(0, per)).join(newline) +} + +// Measured entirely inside the page: the CDP transfer of the payload happens before +// the observation window opens, so it never contributes to the reported latency. +export async function measureComposerPaste(page: Page, selector: string, text: string): Promise { + return page.evaluate( + async ([target, payload]) => { + const editor = document.querySelector(target) + if (!(editor instanceof HTMLElement)) throw new Error(`No composer editor for ${target}`) + + const longTasks: number[] = [] + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => longTasks.push(entry.duration)) + }) + observer.observe({ entryTypes: ["longtask"] }) + + editor.focus() + const selection = window.getSelection() + const caret = document.createRange() + caret.selectNodeContents(editor) + caret.collapse(false) + selection?.removeAllRanges() + selection?.addRange(caret) + + const transfer = new DataTransfer() + transfer.setData("text/plain", payload) + + const start = performance.now() + editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer })) + const dispatched = performance.now() + + // Two consecutive frames means reactive effects, editor reconciliation, layout + // and paint have all drained, so the editor is interactive again. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + const settled = performance.now() + observer.disconnect() + + // textContent drops line breaks, which both composers model as
(and which + // Chromium additionally wraps in block containers), so read the editor the same + // way the composers parse it back into prompt parts. + const blocks = new Set(["DIV", "P", "LI", "PRE"]) + const read = (root: Node) => { + let out = "" + const walk = (node: Node) => { + node.childNodes.forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + out += child.nodeValue ?? "" + return + } + if (!(child instanceof HTMLElement)) return + if (child.tagName === "BR") { + out += "\n" + return + } + if (out.length > 0 && blocks.has(child.tagName)) out += "\n" + walk(child) + }) + } + walk(root) + return out + } + + const expected = payload.replace(/\r\n?/g, "\n") + const actual = read(editor) + .replace(/\u200B/g, "") + .replace(/\r\n?/g, "\n") + return { + chars: payload.length, + dispatchMs: dispatched - start, + settleMs: settled - start, + longTasks: longTasks.length, + longestTaskMs: longTasks.reduce((max, value) => Math.max(max, value), 0), + blockedMs: longTasks.reduce((total, value) => total + value, 0), + editorElements: editor.querySelectorAll("*").length, + editorTextLength: actual.length, + lossless: actual.endsWith(expected), + } + }, + [selector, text] as const, + ) +} + +export type ComposerTypingSample = { syncMs: number; settleMs: number } + +// A paste is a single event, but the draft it leaves behind is what every following keystroke +// has to walk. Typing is measured separately so a fast paste cannot hide a composer that has +// become unusable afterwards. +export async function measureComposerTyping(page: Page, selector: string, keystrokes: number) { + return page.evaluate( + async ([target, count]) => { + const editor = document.querySelector(target) + if (!(editor instanceof HTMLElement)) throw new Error(`No composer editor for ${target}`) + editor.focus() + const caret = document.createRange() + caret.selectNodeContents(editor) + caret.collapse(false) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(caret) + + const samples: { syncMs: number; settleMs: number }[] = [] + for (let index = 0; index < count; index += 1) { + const start = performance.now() + document.execCommand("insertText", false, "x") + // The input handler runs synchronously inside execCommand, so this is the part of a + // keystroke that the draft size can make unbounded. + const sync = performance.now() + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + samples.push({ syncMs: sync - start, settleMs: performance.now() - start }) + } + return samples + }, + [selector, keystrokes] as const, + ) +} + +export function median(values: number[]) { + const sorted = [...values].sort((a, b) => a - b) + if (sorted.length === 0) return 0 + const middle = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 1) return sorted[middle]! + return (sorted[middle - 1]! + sorted[middle]!) / 2 +} + +export function percentile(values: number[], fraction: number) { + const sorted = [...values].sort((a, b) => a - b) + if (sorted.length === 0) return 0 + return sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)]! +} diff --git a/packages/app/e2e/performance/composer/composer-session.ts b/packages/app/e2e/performance/composer/composer-session.ts new file mode 100644 index 000000000000..75ae97d0b820 --- /dev/null +++ b/packages/app/e2e/performance/composer/composer-session.ts @@ -0,0 +1,54 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import type { Page } from "@playwright/test" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { expectAppVisible } from "../../utils/waits" +import { expect } from "../benchmark" + +const directory = "C:/OpenCode/ComposerPasteBenchmark" +const projectID = "proj_composer_paste_benchmark" +const sessionID = "ses_composer_paste_benchmark" + +// Opens a session against the mock server with the requested composer, and returns the +// selector both composers put on their editor. +export async function openComposerSession(page: Page, newLayout: boolean) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "composer-paste-benchmark", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "composer-paste-benchmark", + projectID, + directory, + title: "Composer paste benchmark", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript((layout) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: layout, layoutTransitionEligible: true } }), + ) + // A profile that has never launched the app counts as an upgrade, and the upgrade + // migration forces the new layout on regardless of the stored preference. + localStorage.setItem("app-version.v1", JSON.stringify({ version: "99.0.0" })) + }, newLayout) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]').first()) + // Both composers mark their editor with the same attribute, so measuring the wrong one is + // invisible in the results unless the V2 wrapper is checked explicitly. The layout only + // settles once the persisted settings resolve, so this has to be a retrying assertion. + await expect(page.locator('[data-component="prompt-input-v2"]')).toHaveCount(newLayout ? 1 : 0) + return '[data-component="prompt-input"]' +} diff --git a/packages/app/e2e/performance/composer/composer-typing-benchmark.spec.ts b/packages/app/e2e/performance/composer/composer-typing-benchmark.spec.ts new file mode 100644 index 000000000000..d640e842477a --- /dev/null +++ b/packages/app/e2e/performance/composer/composer-typing-benchmark.spec.ts @@ -0,0 +1,42 @@ +import { benchmark } from "../benchmark" +import { openComposerSession } from "./composer-session" +import { + buildFixtureText, + measureComposerPaste, + measureComposerTyping, + median, + percentile, + type ComposerPasteFixture, +} from "./composer-paste-probe" + +const keystrokes = Number(process.env.OPENCODE_TYPING_KEYSTROKES ?? 20) + +const fixtures: ComposerPasteFixture[] = [ + { label: "64KiB-multiline", chars: 64 * 1024, lines: 800 }, + { label: "100KiB-log", chars: 100 * 1024, lines: 1200 }, +] + +for (const composer of ["v2", "legacy"] as const) { + for (const fixture of fixtures) { + benchmark(`composer typing after paste ${composer} ${fixture.label}`, async ({ page, report }) => { + benchmark.setTimeout(5 * 60_000) + const selector = await openComposerSession(page, composer === "v2") + await measureComposerPaste(page, selector, buildFixtureText(fixture)) + + const samples = await measureComposerTyping(page, selector, keystrokes) + const sync = samples.map((sample) => sample.syncMs) + const settle = samples.map((sample) => sample.settleMs) + report( + { + keystrokes: samples.length, + syncMedianMs: median(sync), + syncP95Ms: percentile(sync, 0.95), + syncMaxMs: Math.max(...sync), + settleMedianMs: median(settle), + settleP95Ms: percentile(settle, 0.95), + }, + { composer, fixture: fixture.label, chars: fixture.chars, lines: fixture.lines }, + ) + }) + } +} diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts index 104921697da5..48421d8a1fac 100644 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ b/packages/app/src/components/prompt-input/attachments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { attachmentMime, pickAttachmentFiles } from "./files" -import { pasteMode } from "./paste" +import { pasteMode } from "@opencode-ai/session-ui/v2/prompt-input/paste" describe("attachmentMime", () => { test("keeps PDFs when the browser reports the mime", async () => { diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 6f3ef57c66e2..c3047fa7e576 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -8,7 +8,7 @@ import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" import { createBlobReference, type DraftStore } from "@/utils/draft-store" import { attachmentMime } from "./files" -import { normalizePaste, pasteMode } from "./paste" +import { normalizePaste, pasteMode } from "@opencode-ai/session-ui/v2/prompt-input/paste" type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts index 8575140d7d54..83b29d811b2b 100644 --- a/packages/app/src/components/prompt-input/editor-dom.ts +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -27,13 +27,23 @@ export function createTextFragment(content: string): DocumentFragment { return fragment } +// U+200B is a rendering aid the editor inserts around pills, so it never counts towards a +// prompt offset. Counting in place avoids allocating a stripped copy of every node. +function visibleLength(value: string, limit = value.length): number { + let length = 0 + for (let index = 0; index < Math.min(limit, value.length); index += 1) { + if (value.charCodeAt(index) !== 0x200b) length += 1 + } + return length +} + export function getNodeLength(node: Node): number { if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - return (node.textContent ?? "").replace(/\u200B/g, "").length + return visibleLength(node.textContent ?? "") } export function getTextLength(node: Node): number { - if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.TEXT_NODE) return visibleLength(node.textContent ?? "") if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 let length = 0 for (const child of Array.from(node.childNodes)) { @@ -47,10 +57,37 @@ export function getCursorPosition(parent: HTMLElement): number { if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) if (!parent.contains(range.startContainer)) return 0 - const preCaretRange = range.cloneRange() - preCaretRange.selectNodeContents(parent) - preCaretRange.setEnd(range.startContainer, range.startOffset) - return getTextLength(preCaretRange.cloneContents()) + return getRangeOffset(parent, range.startContainer, range.startOffset) +} + +// Walks the live tree and stops at the caret. Cloning the range contents instead copied +// the entire pasted document on every keystroke. +function getRangeOffset(parent: HTMLElement, container: Node, offset: number): number { + let total = 0 + + const visit = (node: Node): boolean => { + if (node === container && node.nodeType === Node.TEXT_NODE) { + total += visibleLength(node.textContent ?? "", offset) + return true + } + if (node.nodeType === Node.TEXT_NODE) { + total += visibleLength(node.textContent ?? "") + return false + } + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") { + total += 1 + return false + } + const children = Array.from(node.childNodes) + const limit = node === container ? Math.min(offset, children.length) : children.length + for (let index = 0; index < limit; index += 1) { + if (visit(children[index]!)) return true + } + return node === container + } + + visit(parent) + return total } export function setCursorPosition(parent: HTMLElement, position: number) { diff --git a/packages/app/src/components/prompt-input/paste.ts b/packages/app/src/components/prompt-input/paste.ts deleted file mode 100644 index 6787d5030907..000000000000 --- a/packages/app/src/components/prompt-input/paste.ts +++ /dev/null @@ -1,24 +0,0 @@ -const LARGE_PASTE_CHARS = 8000 -const LARGE_PASTE_BREAKS = 120 - -function largePaste(text: string) { - if (text.length >= LARGE_PASTE_CHARS) return true - let breaks = 0 - for (const char of text) { - if (char !== "\n") continue - breaks += 1 - if (breaks >= LARGE_PASTE_BREAKS) return true - } - return false -} - -export function normalizePaste(text: string) { - if (!text.includes("\r")) return text - return text.replace(/\r\n?/g, "\n") -} - -export function pasteMode(text: string) { - if (largePaste(text)) return "manual" - if (text.includes("\n") || text.includes("\r")) return "manual" - return "native" -} diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts index cd0895f52ce9..e750c826985b 100644 --- a/packages/app/src/utils/draft-store.ts +++ b/packages/app/src/utils/draft-store.ts @@ -10,7 +10,12 @@ type Driver = { getBlob(id: string): Promise } -export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise } +export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise; flush(): Promise } + +// Every keystroke changes the draft, and encoding one means parsing, walking and +// re-serialising the whole prompt before it crosses to storage (an IPC hop on desktop). +// Coalescing collapses a burst of edits, or one large paste, into a single write. +const WRITE_DELAY = 150 const urls = new Map() function blobUrl(id: string, blob: Blob) { @@ -75,22 +80,63 @@ export function createDraftStore(driver: Driver): DraftStore { await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])), ) } + const pending = new Map() + const timers = new Map>() + const write = async (key: string) => { + const value = pending.get(key) + if (value === undefined) return + pending.delete(key) + const version = (versions.get(key) ?? 0) + 1 + versions.set(key, version) + const encoded = JSON.stringify(await encode(JSON.parse(value))) + // A slower encode started earlier must never land on top of a newer draft. + if (versions.get(key) === version) await driver.set(key, encoded) + } + const cancel = (key: string) => { + const timer = timers.get(key) + if (timer === undefined) return + clearTimeout(timer) + timers.delete(key) + } + const flush = async () => { + Array.from(timers.keys()).forEach(cancel) + await Promise.all(Array.from(pending.keys()).map(write)) + } + if (typeof window !== "undefined") { + // Losing the tail of a draft on close would defeat the point of persisting it. + window.addEventListener("pagehide", () => void flush()) + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") void flush() + }) + } return { getItem: async (key) => { + // A queued write is newer than anything the driver can return, and it is already in + // the shape the app wrote, so hydration after a session switch stays correct. + const queued = pending.get(key) + if (queued !== undefined) return queued const value = await driver.get(key) return value === null ? null : JSON.stringify(await decode(JSON.parse(value))) }, setItem: async (key, value) => { - const version = (versions.get(key) ?? 0) + 1 - versions.set(key, version) - const encoded = JSON.stringify(await encode(JSON.parse(value))) - if (versions.get(key) === version) await driver.set(key, encoded) + pending.set(key, value) + if (timers.has(key)) return + timers.set( + key, + setTimeout(() => { + timers.delete(key) + void write(key) + }, WRITE_DELAY), + ) }, removeItem: async (key) => { + cancel(key) + pending.delete(key) versions.set(key, (versions.get(key) ?? 0) + 1) await driver.remove(key) }, putBlob, + flush, } } diff --git a/packages/app/test-browser/prompt-input-v2-paste.test.ts b/packages/app/test-browser/prompt-input-v2-paste.test.ts new file mode 100644 index 000000000000..e44c20703114 --- /dev/null +++ b/packages/app/test-browser/prompt-input-v2-paste.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { + promptInputV2Cursor, + promptInputV2Offset, + promptInputV2SelectionRange, +} from "@opencode-ai/session-ui/v2/prompt-input/editor" +import { createPromptInputV2Controller } from "@opencode-ai/session-ui/v2/prompt-input/interaction" +import type { PromptInputV2PersistedState } from "@opencode-ai/session-ui/v2/prompt-input/types" + +function editorElement(html?: string) { + const editor = document.createElement("div") + editor.contentEditable = "true" + if (html !== undefined) editor.innerHTML = html + document.body.appendChild(editor) + return editor +} + +function select(node: Node, start: number, end = start, endNode: Node = node) { + const range = document.createRange() + range.setStart(node, start) + range.setEnd(endNode, end) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return range +} + +function pasteEvent(text: string) { + const transfer = new DataTransfer() + transfer.setData("text/plain", text) + return new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }) +} + +function controller(state: PromptInputV2PersistedState, editor: HTMLElement) { + return createRoot((dispose) => { + const store = createStore(state) + const instance = createPromptInputV2Controller({ + store, + commands: () => [], + context: () => [], + searchContextFiles: () => [], + view: { submit: { stopping: () => false, onSubmit: () => undefined, onStop: () => undefined } }, + }) + instance.setEditor(editor) + return { instance, dispose } + }) +} + +function content(part: PromptInputV2PersistedState["prompt"][number] | undefined) { + return part && "content" in part ? part.content : "" +} + +function state(content: string): PromptInputV2PersistedState { + return { + prompt: [{ type: "text", content, start: 0, end: content.length }], + cursor: content.length, + context: { items: [] }, + } +} + +describe("prompt input v2 editor offsets", () => { + test("counts text, line breaks and mentions without materializing the document", () => { + const editor = editorElement('one
two@a.tstail') + const nodes = Array.from(editor.childNodes) + + expect(promptInputV2Offset(editor, nodes[0], 3)).toBe(3) + expect(promptInputV2Offset(editor, nodes[2], 0)).toBe(4) + expect(promptInputV2Offset(editor, nodes[2], 3)).toBe(7) + expect(promptInputV2Offset(editor, editor, 3)).toBe(7) + expect(promptInputV2Offset(editor, editor, 4)).toBe(12) + expect(promptInputV2Offset(editor, nodes[4], 4)).toBe(16) + }) + + test("treats block siblings of the editor as line breaks", () => { + const editor = editorElement("
one
two
three
") + const last = editor.childNodes[2].firstChild as Node + + expect(promptInputV2Offset(editor, last, 0)).toBe(8) + }) + + test("reports a caret in a million character node without copying it", () => { + const editor = editorElement() + const node = document.createTextNode("x".repeat(1_000_000)) + editor.appendChild(node) + select(node, 999_999) + + const started = performance.now() + expect(promptInputV2Cursor(editor)).toBe(999_999) + // Range.toString() on this editor allocates a megabyte on every keystroke. + expect(performance.now() - started).toBeLessThan(50) + }) + + test("reports collapsed and expanded selections", () => { + const editor = editorElement("hello world") + const node = editor.firstChild as Node + + select(node, 4) + expect(promptInputV2SelectionRange(editor)).toEqual({ start: 4, end: 4 }) + + select(node, 2, 7) + expect(promptInputV2SelectionRange(editor)).toEqual({ start: 2, end: 7 }) + }) + + test("ignores a selection that is outside the editor", () => { + const editor = editorElement("inside") + const outside = editorElement("outside") + select(outside.firstChild as Node, 3) + + expect(promptInputV2SelectionRange(editor)).toBeUndefined() + }) +}) + +describe("prompt input v2 large paste", () => { + test("applies a large paste to the prompt model instead of the editor DOM", () => { + const editor = editorElement("start") + const initial = state("start") + const { instance, dispose } = controller(initial, editor) + const text = Array.from({ length: 12_000 }, (_, index) => `${index} ${"log line ".repeat(9)}`).join("\n") + + select(editor.firstChild as Node, 5) + const event = pasteEvent(text) + instance.onPaste(event) + + expect(event.defaultPrevented).toBe(true) + expect(instance.parts().length).toBe(1) + expect(instance.parts()[0]).toMatchObject({ type: "text", content: `start${text}` }) + // The browser never saw the payload, so the editor still holds its single text node. + expect(editor.querySelectorAll("*").length).toBe(0) + dispose() + }) + + test("replaces the selected range rather than appending", () => { + const editor = editorElement("keep REPLACE keep") + const { instance, dispose } = controller(state("keep REPLACE keep"), editor) + const text = "y".repeat(9_000) + + select(editor.firstChild as Node, 5, 12) + instance.onPaste(pasteEvent(text)) + + expect(instance.parts()[0]).toMatchObject({ type: "text", content: `keep ${text} keep` }) + dispose() + }) + + test("inserts at the caret in the middle of an existing draft", () => { + const editor = editorElement("abcdef") + const { instance, dispose } = controller(state("abcdef"), editor) + const text = "z".repeat(8_000) + + select(editor.firstChild as Node, 3) + instance.onPaste(pasteEvent(text)) + + expect(instance.parts()[0]).toMatchObject({ type: "text", content: `abc${text}def` }) + dispose() + }) + + test("normalizes CRLF and keeps the payload lossless", () => { + const editor = editorElement() + const { instance, dispose } = controller(state(""), editor) + const source = Array.from({ length: 400 }, (_, index) => `${index} value`).join("\r\n") + + select(editor, 0) + instance.onPaste(pasteEvent(source)) + + const pasted = content(instance.parts()[0]) + expect(pasted).not.toContain("\r") + expect(pasted).toBe(source.replace(/\r\n/g, "\n")) + expect(pasted.split("\n").length).toBe(400) + dispose() + }) + + test("keeps a structured mention that the paste does not touch", () => { + const editor = editorElement('see @src/a.ts now') + const { instance, dispose } = controller( + { + prompt: [ + { type: "text", content: "see ", start: 0, end: 4 }, + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 4, end: 13 }, + { type: "text", content: " now", start: 13, end: 17 }, + ], + cursor: 17, + context: { items: [] }, + }, + editor, + ) + const text = "q".repeat(8_000) + + select(editor.childNodes[2], 4) + instance.onPaste(pasteEvent(text)) + + expect(instance.parts().map((part) => part.type)).toEqual(["text", "file", "text"]) + expect(instance.parts()[1]).toMatchObject({ type: "file", path: "src/a.ts", content: "@src/a.ts" }) + expect(instance.parts()[2]).toMatchObject({ content: ` now${text}` }) + dispose() + }) + + test("removes a mention that the replaced selection covers", () => { + const editor = editorElement('see @src/a.ts now') + const { instance, dispose } = controller( + { + prompt: [ + { type: "text", content: "see ", start: 0, end: 4 }, + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 4, end: 13 }, + { type: "text", content: " now", start: 13, end: 17 }, + ], + cursor: 17, + context: { items: [] }, + }, + editor, + ) + const text = "r".repeat(8_000) + + select(editor.childNodes[0], 2, 2, editor.childNodes[2]) + instance.onPaste(pasteEvent(text)) + + expect(instance.parts().map((part) => part.type)).toEqual(["text", "text"]) + expect(instance.parts()[0]).toMatchObject({ content: `se${text}` }) + expect(instance.parts()[1]).toMatchObject({ content: "ow" }) + dispose() + }) + + test("leaves short single-line pastes to the browser", () => { + const editor = editorElement("abc") + const { instance, dispose } = controller(state("abc"), editor) + + select(editor.firstChild as Node, 3) + instance.onPaste(pasteEvent("short")) + + // The native path must not write the model itself, or the paste would land twice. + expect(instance.parts()[0]).toMatchObject({ content: "abc" }) + dispose() + }) + + test("applies a megabyte paste in a single model transaction", () => { + const editor = editorElement() + const { instance, dispose } = controller(state(""), editor) + const text = "m".repeat(1024 * 1024) + + select(editor, 0) + const started = performance.now() + instance.onPaste(pasteEvent(text)) + const elapsed = performance.now() - started + + expect(content(instance.parts()[0]).length).toBe(1024 * 1024) + expect(instance.parts().length).toBe(1) + expect(elapsed).toBeLessThan(500) + dispose() + }) +}) diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts index 7c3d28226f43..78a752c3f22d 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -28,6 +28,7 @@ const platform: Platform = { putBlob: async () => { throw new Error("putBlob is not used by this test") }, + flush: async () => undefined, }, } @@ -81,6 +82,7 @@ test("moves legacy image data URLs into blobs and hydrates object URLs", async ( }) await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] })) + await store.flush() expect(documents.get("prompt")).not.toContain("dataUrl") const value = JSON.parse((await store.getItem("prompt"))!) expect(value.prompt[0].blob.id).toBe("1") @@ -100,14 +102,86 @@ test("does not let delayed blob migration overwrite a newer draft", async () => }, getBlob: async () => null, }) - const older = store.setItem( - "prompt", - JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }), - ) + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] })) + const older = store.flush() await Bun.sleep(0) await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] })) + await store.flush() migration.resolve() await older expect(documents.get("prompt")).toContain("latest") }) + +test("coalesces a burst of edits into a single encoded write", async () => { + const writes: string[] = [] + const store = createDraftStore({ + get: async () => null, + set: async (_key, value) => void writes.push(value), + remove: async () => undefined, + putBlob: async () => "blob", + getBlob: async () => null, + }) + + for (let index = 0; index < 50; index += 1) { + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "x".repeat(index) }] })) + } + expect(writes).toEqual([]) + + await store.flush() + + expect(writes.length).toBe(1) + expect(JSON.parse(writes[0] ?? "{}").prompt[0].content).toBe("x".repeat(49)) +}) + +test("serves a queued draft to a reader that has not been flushed yet", async () => { + const documents = new Map() + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => "blob", + getBlob: async () => null, + }) + + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "queued" }] })) + + expect(documents.has("prompt")).toBe(false) + expect(JSON.parse((await store.getItem("prompt")) ?? "{}").prompt[0].content).toBe("queued") +}) + +test("drops a queued draft when it is removed before the write lands", async () => { + const documents = new Map([["prompt", JSON.stringify({ prompt: [] })]]) + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => "blob", + getBlob: async () => null, + }) + + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "typed" }] })) + await store.removeItem("prompt") + await store.flush() + + expect(documents.has("prompt")).toBe(false) + expect(await store.getItem("prompt")).toBeNull() +}) + +test("keeps drafts for separate sessions independent", async () => { + const documents = new Map() + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => "blob", + getBlob: async () => null, + }) + + await store.setItem("session-a", JSON.stringify({ prompt: [{ type: "text", content: "a" }] })) + await store.setItem("session-b", JSON.stringify({ prompt: [{ type: "text", content: "b" }] })) + await store.flush() + + expect(JSON.parse(documents.get("session-a")!).prompt[0].content).toBe("a") + expect(JSON.parse(documents.get("session-b")!).prompt[0].content).toBe("b") +}) diff --git a/packages/desktop/src/main/draft-store.test.ts b/packages/desktop/src/main/draft-store.test.ts index 95d4e7aa803b..f62e2e408953 100644 --- a/packages/desktop/src/main/draft-store.test.ts +++ b/packages/desktop/src/main/draft-store.test.ts @@ -1,6 +1,14 @@ import { expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { createDesktopDraftStore } from "./draft-store" +function tempFile() { + const directory = mkdtempSync(join(tmpdir(), "opencode-drafts-")) + return { path: join(directory, "drafts.sqlite"), cleanup: () => rmSync(directory, { recursive: true, force: true }) } +} + test("flushes the latest buffered draft and stores blobs", () => { const store = createDesktopDraftStore(":memory:") store.set("prompt", "first") @@ -14,3 +22,78 @@ test("flushes the latest buffered draft and stores blobs", () => { expect(store.getBlob(id)).toEqual(bytes) store.close() }) + +test("keeps blobs a draft still references and drops the rest", () => { + const store = createDesktopDraftStore(":memory:") + const kept = store.putBlob(new TextEncoder().encode("kept")) + const orphan = store.putBlob(new TextEncoder().encode("orphan")) + store.set("prompt", JSON.stringify({ prompt: [{ type: "image", blob: { id: kept } }] })) + + // The draft is still buffered here, so the sweep has to flush before it can see the reference. + store.collectBlobs() + + expect(store.getBlob(kept)).not.toBeNull() + expect(store.getBlob(orphan)).toBeNull() + store.close() +}) + +test("reopens without reading a huge draft back into the main process", () => { + const file = tempFile() + const huge = JSON.stringify({ prompt: [{ type: "text", content: "x".repeat(4 * 1024 * 1024) }] }) + try { + const first = createDesktopDraftStore(file.path) + const kept = first.putBlob(new TextEncoder().encode("attachment")) + const orphan = first.putBlob(new TextEncoder().encode("orphan")) + first.set("session-a", huge) + first.set("session-b", JSON.stringify({ prompt: [{ type: "image", blob: { id: kept } }] })) + first.close() + + const second = createDesktopDraftStore(file.path) + + // Startup used to JSON.parse every stored draft to find blob references, so a draft this + // size blocked the main process before the first window could open. Timing it here would be + // flaky, so this asserts the reason instead: opening collects nothing, which is the only way + // it can avoid looking through the drafts, and the orphan survives until the sweep runs. + expect(second.getBlob(orphan)).not.toBeNull() + expect(second.get("session-a")).toBe(huge) + expect(second.getBlob(kept)).not.toBeNull() + + second.collectBlobs() + expect(second.getBlob(orphan)).toBeNull() + expect(second.getBlob(kept)).not.toBeNull() + + // The draft is still editable and removable after the restart. + second.set("session-a", JSON.stringify({ prompt: [{ type: "text", content: "small" }] })) + second.flush() + expect(second.get("session-a")).toContain("small") + second.set("session-a", null) + second.flush() + expect(second.get("session-a")).toBeNull() + second.close() + + const third = createDesktopDraftStore(file.path) + expect(third.get("session-a")).toBeNull() + third.close() + } finally { + file.cleanup() + } +}) + +test("opens even when a stored draft is not valid JSON", () => { + const file = tempFile() + try { + const first = createDesktopDraftStore(file.path) + first.putBlob(new TextEncoder().encode("attachment")) + first.set("broken", "{not json") + first.close() + + // A row the main process cannot read must not turn into a startup crash loop, and the + // sweep that runs afterwards must not either. + const second = createDesktopDraftStore(file.path) + second.collectBlobs() + expect(second.get("broken")).toBe("{not json") + second.close() + } finally { + file.cleanup() + } +}) diff --git a/packages/desktop/src/main/draft-store.ts b/packages/desktop/src/main/draft-store.ts index 839eff26afe6..cb6535738fb9 100644 --- a/packages/desktop/src/main/draft-store.ts +++ b/packages/desktop/src/main/draft-store.ts @@ -19,21 +19,32 @@ export function createDesktopDraftStore(filename: string) { "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);", ) const db = drizzle({ client: native }) - const used = new Set() - db.select({ value: documents.value }) - .from(documents) - .all() - .forEach(({ value }) => - JSON.parse(value, (_key, item) => { - if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id) - return item - }), - ) - db.select({ id: blobs.id }) - .from(blobs) - .all() - .filter(({ id }) => !used.has(id)) - .forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run()) + // Reclaiming unreferenced blobs means looking through the drafts for the ids they mention, + // so its cost follows how much text the profile has saved. Opening the store used to read + // every draft back and JSON.parse it, which put megabytes of text in front of the first + // window. Nothing about it is needed for the app to start, so it runs on a timer instead, + // and a profile with no blobs never looks at a draft at all. + const collectBlobs = () => { + try { + // A draft still sitting in the write buffer already references its attachment, so the + // sweep has to see it before deciding the blob is unused. + flush() + const stored = native.prepare("SELECT id FROM blob").all() as { id: string }[] + if (stored.length === 0) return + // LIKE stops at the first draft that mentions the blob, so a blob still in use costs + // far less than a full pass. Binding the pattern keeps SQLite from rebuilding it per row. + const referenced = native.prepare("SELECT 1 FROM document WHERE value LIKE ? LIMIT 1") + const remove = native.prepare("DELETE FROM blob WHERE id = ?") + stored.forEach(({ id }) => { + if (referenced.get(`%"id":"${id}"%`)) return + remove.run(id) + }) + } catch { + // Keeping an orphaned blob costs disk space; failing to open the store costs the app. + } + } + let collector: ReturnType | undefined = setTimeout(collectBlobs, 10_000) + collector.unref?.() const pending = new Map() let timer: ReturnType | undefined const flush = () => { @@ -74,7 +85,10 @@ export function createDesktopDraftStore(filename: string) { }, getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null, flush, + collectBlobs, close() { + if (collector) clearTimeout(collector) + collector = undefined flush() native.close() }, diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 854d3f04381d..f9daa4bfad23 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -20,7 +20,9 @@ "./v2/*.css": "./src/v2/components/*.css", "./v2/*": "./src/v2/components/*.tsx", "./v2/prompt-input": "./src/v2/components/prompt-input/index.tsx", + "./v2/prompt-input/editor": "./src/v2/components/prompt-input/editor.ts", "./v2/prompt-input/interaction": "./src/v2/components/prompt-input/interaction.ts", + "./v2/prompt-input/paste": "./src/v2/components/prompt-input/paste.ts", "./v2/prompt-input/store": "./src/v2/components/prompt-input/store.ts", "./v2/prompt-input/types": "./src/v2/components/prompt-input/types.ts" }, diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index 89f38ec4a327..a75c5db1283f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -1,5 +1,6 @@ import { onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" +import { promptInputV2Offset } from "./editor" import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" const accepted = [ @@ -156,24 +157,12 @@ export function createPromptInputV2Attachments( await addAttachments(files, true, target) return } - const plainText = clipboardData.getData("text/plain") ?? "" - if (input.readClipboardImage && !plainText) { + // Text is handled by the controller, which owns the caret and the selection. This is only + // reached when the clipboard carries no text at all, so a native image can still be read. + if (input.readClipboardImage && !clipboardData.getData("text/plain")) { const file = await input.readClipboardImage() - if (file && (await add(file, true, target, true))) return + if (file) await add(file, true, target, true) } - if (!plainText) return - const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText - const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) - } - if (text.includes("\n") || largePaste(text)) { - put() - return - } - if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return - put() } const handleDrop = async (event: DragEvent) => { if (input.isDialogActive()) return @@ -265,14 +254,6 @@ function cursorPosition(editor: HTMLElement) { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) - if (!editor.contains(range.startContainer)) return 0 - const before = range.cloneRange() - before.selectNodeContents(editor) - before.setEnd(range.startContainer, range.startOffset) - return before.toString().replace(/\u200B/g, "").length -} - -function largePaste(text: string) { - if (text.length >= 8000) return true - return text.split("\n").length - 1 >= 120 + const offset = promptInputV2Offset(editor, range.startContainer, range.startOffset) + return offset < 0 ? 0 : offset } diff --git a/packages/session-ui/src/v2/components/prompt-input/editor.ts b/packages/session-ui/src/v2/components/prompt-input/editor.ts new file mode 100644 index 000000000000..d49a0b6b4e19 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/editor.ts @@ -0,0 +1,63 @@ +// Offsets between the editor DOM and the prompt model. The rules here mirror +// parsePromptInputV2Editor: a
counts as one newline, mentions count as their own +// text, and a block sibling of the editor ends a line. Everything walks the live tree +// and stops at the caret, so no part of the document is ever materialised as a string. + +export function promptInputV2Offset(editor: HTMLElement, container: Node | null | undefined, offset: number) { + if (!container || !editor.contains(container)) return -1 + let total = 0 + + const visit = (node: Node): boolean => { + if (node === container && node.nodeType === Node.TEXT_NODE) { + total += Math.min(offset, (node.nodeValue ?? "").length) + return true + } + if (node.nodeType === Node.TEXT_NODE) { + total += (node.nodeValue ?? "").length + return false + } + if (!(node instanceof HTMLElement)) return false + if (node.tagName === "BR") { + total += 1 + return false + } + if (node.dataset.mention) { + total += (node.textContent ?? "").length + return false + } + const children = Array.from(node.childNodes) + const limit = node === container ? Math.min(offset, children.length) : children.length + for (let index = 0; index < limit; index += 1) { + if (visit(children[index]!)) return true + const child = children[index] + if (node !== editor || index === children.length - 1) continue + if (child instanceof HTMLElement && (child.tagName === "DIV" || child.tagName === "P")) total += 1 + } + return node === container + } + + visit(editor) + return total +} + +export function promptInputV2Cursor(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection?.rangeCount) return promptInputV2Length(editor) + const offset = promptInputV2Offset(editor, selection.anchorNode, selection.anchorOffset) + return offset < 0 ? promptInputV2Length(editor) : offset +} + +export function promptInputV2SelectionRange(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection?.rangeCount) return undefined + const range = selection.getRangeAt(0) + const start = promptInputV2Offset(editor, range.startContainer, range.startOffset) + if (start < 0) return undefined + if (range.collapsed) return { start, end: start } + const end = promptInputV2Offset(editor, range.endContainer, range.endOffset) + return { start, end: end < 0 ? start : end } +} + +function promptInputV2Length(editor: HTMLElement) { + return promptInputV2Offset(editor, editor, editor.childNodes.length) +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff4ff0f1d408..6f1ce88d872f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -22,6 +22,7 @@ import type { PromptInputV2Suggestion, } from "./types" import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" +import { promptInputV2Cursor } from "./editor" import "./attachments.css" export type { @@ -182,7 +183,7 @@ export function PromptInputV2(props: PromptInputV2Props) { onPaste={props.controller.onPaste} onFocus={() => props.controller.dispatch({ type: "focus.editor" })} /> - +
("content" in part ? part.content : "")).join("") - if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false + const length = promptLength(draft.state.prompt) + if (!canNavigateHistory(direction, length, promptInputV2Cursor(editor), state.historyIndex >= 0)) return false const entries = input.history.entries(state.mode) if (direction === "up") { if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false @@ -298,6 +300,11 @@ export function createPromptInputV2Controller(input: { value() { return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") }, + // The placeholder only needs to know whether anything was typed, so it must not join + // the whole draft into a string on every prompt change. + empty() { + return draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0) + }, parts() { return draft.state.prompt }, @@ -382,9 +389,25 @@ export function createPromptInputV2Controller(input: { } input.view.onPaste?.(event) if (event.defaultPrevented) return - const text = clipboard?.getData("text/plain") - if (!text) return + const plain = clipboard?.getData("text/plain") + if (!plain) return event.preventDefault() + const text = normalizePaste(plain) + // Large and multi-line pastes are applied to the prompt model directly. Letting the + // browser insert them builds one node per line, and every following keystroke then + // has to walk that document back into prompt parts. + if (pasteMode(text) === "manual") { + const range = (editor && promptInputV2SelectionRange(editor)) ?? { + start: promptLength(draft.state.prompt), + end: promptLength(draft.state.prompt), + } + draft.replaceText(range.start, range.end, text) + // Pasted bulk text never opens a mention or command popover, and matching it + // against the suggestion patterns would scan the whole payload. + dispatch({ type: "popover.close" }) + restoreFocus(Math.min(range.start, range.end) + text.length) + return + } if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return const target = event.currentTarget const selection = window.getSelection() @@ -434,11 +457,13 @@ export function createPromptInputV2Controller(input: { export type PromptInputV2Interaction = ReturnType -function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) { - const position = Math.max(0, Math.min(cursor, text.length)) - if (inHistory) return position === 0 || position === text.length - if (direction === "up") return position === 0 && text.length === 0 - return position === text.length +// Only the caret's position within the draft decides this, so it takes the length rather than +// the draft itself. Joining the prompt here copied the whole thing on every arrow key. +function canNavigateHistory(direction: "up" | "down", length: number, cursor: number, inHistory: boolean) { + const position = Math.max(0, Math.min(cursor, length)) + if (inHistory) return position === 0 || position === length + if (direction === "up") return position === 0 && length === 0 + return position === length } function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] { @@ -451,15 +476,6 @@ function promptLength(prompt: PromptInputV2PersistedState["prompt"]) { return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) } -function editorCursor(editor: HTMLElement) { - const selection = window.getSelection() - if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(editor) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length -} - function setEditorCursor(editor: HTMLElement | undefined, cursor: number) { if (!editor) return const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.ts b/packages/session-ui/src/v2/components/prompt-input/machine.ts index d2508e249c5f..49ec11c55382 100644 --- a/packages/session-ui/src/v2/components/prompt-input/machine.ts +++ b/packages/session-ui/src/v2/components/prompt-input/machine.ts @@ -81,6 +81,20 @@ export function transitionPromptInputV2( return changed({ ...state, focus: "external" }) } +// Mentions and commands are single tokens, so only the text right before the cursor can +// match. Bounding the scan keeps a draft holding a pasted document off the hot path, +// where the whole thing used to be copied on every keystroke. +const SUGGESTION_SCAN = 1024 + +function mentionQuery(value: string, cursor: number | undefined) { + const at = Math.min(cursor ?? value.length, value.length) + const from = Math.max(0, at - SUGGESTION_SCAN) + const tail = from === 0 && at === value.length ? value : value.slice(from, at) + // Without the start of the string in view, "@" only opens a mention after whitespace. + const match = tail.match(from === 0 ? /(?:^|\s)@([^\s@]*)$/ : /\s@([^\s@]*)$/) + return match?.[1] ?? undefined +} + function inputChanged( state: PromptInputV2InteractionState, value: string, @@ -93,16 +107,15 @@ function inputChanged( { type: "draft.setText", value: "" }, ]) } - const context = value.slice(0, cursor ?? value.length).match(/(?:^|\s)@([^\s@]*)$/) - if (context) { - const query = context[1] ?? "" - return changed({ ...state, popover: { type: "context", query }, focus: "editor" }, [ + const mention = mentionQuery(value, cursor) + if (mention !== undefined) { + return changed({ ...state, popover: { type: "context", query: mention }, focus: "editor" }, [ ...setText, - { type: "popover.filter", popover: "context", query }, + { type: "popover.filter", popover: "context", query: mention }, ]) } - const command = value.match(/^\/(\S*)$/) + const command = value.length <= SUGGESTION_SCAN ? value.match(/^\/(\S*)$/) : null if (command) { const query = command[1] ?? "" return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [ diff --git a/packages/session-ui/src/v2/components/prompt-input/paste.test.ts b/packages/session-ui/src/v2/components/prompt-input/paste.test.ts new file mode 100644 index 000000000000..6681ee04b8a6 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/paste.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { LARGE_PASTE_BREAKS, LARGE_PASTE_CHARS, largePaste, normalizePaste, pasteMode } from "./paste" + +function line(length: number) { + return "a".repeat(length) +} + +function lines(count: number, newline = "\n") { + return Array.from({ length: count }, (_, index) => `line ${index}`).join(newline) +} + +describe("largePaste", () => { + test("classifies by character count around the threshold", () => { + expect(largePaste(line(1_000))).toBe(false) + expect(largePaste(line(4_999))).toBe(false) + expect(largePaste(line(5_000))).toBe(false) + expect(largePaste(line(LARGE_PASTE_CHARS - 1))).toBe(false) + expect(largePaste(line(LARGE_PASTE_CHARS))).toBe(true) + expect(largePaste(line(LARGE_PASTE_CHARS + 1))).toBe(true) + }) + + test("classifies by line breaks around the threshold", () => { + expect(largePaste(lines(LARGE_PASTE_BREAKS - 1))).toBe(false) + expect(largePaste(lines(LARGE_PASTE_BREAKS))).toBe(false) + expect(largePaste(lines(LARGE_PASTE_BREAKS + 1))).toBe(true) + expect(largePaste(lines(LARGE_PASTE_BREAKS + 2))).toBe(true) + expect(largePaste(lines(200))).toBe(true) + expect(largePaste(lines(201))).toBe(true) + expect(largePaste(lines(5_000))).toBe(true) + }) + + test("counts CRLF breaks the same as LF breaks", () => { + expect(largePaste(lines(LARGE_PASTE_BREAKS, "\r\n"))).toBe(false) + expect(largePaste(lines(LARGE_PASTE_BREAKS + 1, "\r\n"))).toBe(true) + }) + + test("decides multi-megabyte input without walking past the character threshold", () => { + const huge = line(4 * 1024 * 1024) + const started = performance.now() + expect(largePaste(huge)).toBe(true) + // A line-array allocation over 4 MiB takes tens of milliseconds; a length check is + // constant time. The bound is loose on purpose so it cannot flake on slow machines. + expect(performance.now() - started).toBeLessThan(50) + }) +}) + +describe("pasteMode", () => { + test("keeps short single-line text on the native path", () => { + expect(pasteMode("hello world")).toBe("native") + expect(pasteMode(line(1_000))).toBe("native") + expect(pasteMode(line(4_999))).toBe("native") + expect(pasteMode(line(LARGE_PASTE_CHARS - 1))).toBe("native") + expect(pasteMode("@mention and /command stay native")).toBe("native") + }) + + test("takes the model path for anything large or multi-line", () => { + expect(pasteMode(line(LARGE_PASTE_CHARS))).toBe("manual") + expect(pasteMode(line(LARGE_PASTE_CHARS + 1))).toBe("manual") + expect(pasteMode(line(50_000))).toBe("manual") + expect(pasteMode(line(100_000))).toBe("manual") + expect(pasteMode(line(250_000))).toBe("manual") + expect(pasteMode(line(500_000))).toBe("manual") + expect(pasteMode(line(1_000_000))).toBe("manual") + expect(pasteMode("a\nb")).toBe("manual") + expect(pasteMode("a\r\nb")).toBe("manual") + expect(pasteMode("a\rb")).toBe("manual") + }) +}) + +describe("normalizePaste", () => { + test("returns the same string when there is nothing to normalize", () => { + const text = "no carriage returns here" + expect(normalizePaste(text)).toBe(text) + }) + + test("converts CRLF and lone CR to LF", () => { + expect(normalizePaste("a\r\nb\rc\nd")).toBe("a\nb\nc\nd") + }) + + test("preserves every other character, including unicode and zero width", () => { + const text = "héllo 🌍\tタブ\u200Bzero\r\nsecond" + expect(normalizePaste(text)).toBe("héllo 🌍\tタブ\u200Bzero\nsecond") + }) + + test("normalizes a megabyte of CRLF losslessly", () => { + const source = Array.from({ length: 20_000 }, (_, index) => `${index} ${line(40)}`).join("\r\n") + const normalized = normalizePaste(source) + expect(normalized).not.toContain("\r") + expect(normalized.split("\n").length).toBe(20_000) + expect(normalized.length).toBe(source.length - 19_999) + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/paste.ts b/packages/session-ui/src/v2/components/prompt-input/paste.ts new file mode 100644 index 000000000000..f43d80d7fa8c --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/paste.ts @@ -0,0 +1,32 @@ +export const LARGE_PASTE_CHARS = 8000 +export const LARGE_PASTE_BREAKS = 120 + +export type PasteMode = "native" | "manual" + +// The scan stops as soon as the verdict is known, so a multi-megabyte paste never +// allocates a line array just to discover that it is large. +export function largePaste(text: string) { + if (text.length >= LARGE_PASTE_CHARS) return true + let breaks = 0 + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) !== 10) continue + breaks += 1 + if (breaks >= LARGE_PASTE_BREAKS) return true + } + return false +} + +export function normalizePaste(text: string) { + if (!text.includes("\r")) return text + return text.replace(/\r\n?/g, "\n") +} + +// "native" lets the browser insert the text so small edits keep undo history, IME and +// selection semantics. "manual" means the paste has to be applied to the prompt model +// instead, because letting the browser build one node per line is what stalls the +// renderer on large pastes. +export function pasteMode(text: string): PasteMode { + if (largePaste(text)) return "manual" + if (text.includes("\n") || text.includes("\r")) return "manual" + return "native" +} diff --git a/packages/session-ui/src/v2/components/prompt-input/store.test.ts b/packages/session-ui/src/v2/components/prompt-input/store.test.ts index 768a2224507a..1cddb88bd41f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/store.test.ts +++ b/packages/session-ui/src/v2/components/prompt-input/store.test.ts @@ -113,4 +113,68 @@ describe("prompt input v2 store", () => { expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) expect(prompt.state.cursor).toBe(0) }) + + test("replaces a range across parts and leaves the cursor after the replacement", () => { + const prompt = createPromptInputV2Store( + createStore({ + prompt: [ + { type: "text", content: "see ", start: 0, end: 4 }, + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 4, end: 13 }, + { type: "text", content: " now", start: 13, end: 17 }, + ], + cursor: 17, + context: { items: [] }, + }), + ) + + prompt.replaceText(2, 15, "X") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "seX", start: 0, end: 3 }, + { type: "text", content: "ow", start: 3, end: 5 }, + ]) + expect(prompt.state.cursor).toBe(3) + }) + + test("puts the replacement where a fully covered leading mention was", () => { + const prompt = createPromptInputV2Store( + createStore({ + prompt: [ + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 0, end: 9 }, + { type: "text", content: " tail", start: 9, end: 14 }, + ], + cursor: 14, + context: { items: [] }, + }), + ) + + prompt.replaceText(0, 9, "start") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "start", start: 0, end: 5 }, + { type: "text", content: " tail", start: 5, end: 10 }, + ]) + expect(prompt.state.cursor).toBe(5) + }) + + test("inserts at the caret without disturbing a mention that follows it", () => { + const prompt = createPromptInputV2Store( + createStore({ + prompt: [ + { type: "text", content: "see ", start: 0, end: 4 }, + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 4, end: 13 }, + ], + cursor: 4, + context: { items: [] }, + }), + ) + + prompt.replaceText(4, 4, "\nlog\n") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "see \nlog\n", start: 0, end: 9 }, + { type: "file", path: "src/a.ts", content: "@src/a.ts", start: 9, end: 18 }, + ]) + expect(prompt.state.cursor).toBe(9) + }) }) diff --git a/packages/session-ui/src/v2/components/prompt-input/store.ts b/packages/session-ui/src/v2/components/prompt-input/store.ts index 5a27de6c4de3..64f4d270d241 100644 --- a/packages/session-ui/src/v2/components/prompt-input/store.ts +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -24,6 +24,12 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { return typeof value === "function" ? value() : value } const setStore = () => tuple()[1] + const replace = (start: number, end: number, content: string) => { + batch(() => { + setStore()("prompt", (prompt) => replaceRange(prompt, start, end, content)) + setStore()("cursor", Math.min(start, end) + content.length) + }) + } return { get state() { @@ -49,11 +55,11 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { }, addText(content: string) { const cursor = store().cursor ?? promptLength(store().prompt) - batch(() => { - setStore()("prompt", (prompt) => insertText(prompt, cursor, content)) - setStore()("cursor", cursor + content.length) - }) + replace(cursor, cursor, content) }, + // Replaces the selected range in a single transaction. Large pastes go through here + // instead of the DOM, so the editor re-renders from the model as one text node. + replaceText: replace, reset() { batch(() => { setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }]) @@ -74,13 +80,11 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { setStore()("context", "items", (items) => items.filter((item) => item.key !== key)) }, addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) { - const text = store() - .prompt.map((part) => ("content" in part ? part.content : "")) - .join("") - const end = store().cursor ?? text.length - const start = text.slice(0, end).lastIndexOf("@") - setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) - setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) + const end = store().cursor ?? promptLength(store().prompt) + const trigger = mentionStart(store().prompt, end) + const start = trigger < 0 ? end : trigger + setStore()("prompt", insertMention(store().prompt, start, end, mention)) + setStore()("cursor", start + mention.content.length + 1) }, addAttachment(attachment: PromptInputV2Attachment) { setStore()("prompt", (prompt) => [...prompt, attachment]) @@ -93,27 +97,56 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { export type PromptInputV2Store = ReturnType -function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt { +function replaceRange(prompt: PromptInputV2Prompt, start: number, end: number, content: string): PromptInputV2Prompt { + const from = Math.max(0, Math.min(start, end)) + const to = Math.max(0, Math.max(start, end)) let position = 0 let inserted = false const parts = prompt.flatMap((part) => { if (part.type === "image") return [part] - const start = position - position += part.content.length - if (inserted) return [part] - if (part.type === "text" && cursor >= start && cursor <= position) { + const partStart = position + const partEnd = partStart + part.content.length + position = partEnd + if (part.type !== "text") { + // Mentions are atomic, so a selection that reaches into one removes all of it. The + // first one removed takes the replacement's place, or a selection that starts inside a + // mention would push the pasted text behind the parts that follow it. + if (partStart < to && partEnd > from) { + if (inserted) return [] + inserted = true + return [{ type: "text", content, start: 0, end: 0 }] + } + if (inserted || from > partStart) return [part] inserted = true - const offset = cursor - start - return [{ ...part, content: part.content.slice(0, offset) + content + part.content.slice(offset) }] + return [{ type: "text", content, start: 0, end: 0 }, part] } - if (cursor > start) return [part] + const head = part.content.slice(0, Math.max(0, Math.min(part.content.length, from - partStart))) + const tail = part.content.slice(Math.max(0, Math.min(part.content.length, to - partStart))) + if (inserted) return [{ ...part, content: head + tail }] + if (partEnd < from || partStart > to) return [part] inserted = true - return [{ type: "text", content, start: 0, end: 0 }, part] + return [{ ...part, content: head + content + tail }] }) if (!inserted) parts.push({ type: "text", content, start: 0, end: 0 }) return withOffsets(parts) } +// Finds the "@" that opened the current mention without joining the prompt into one +// string, which would copy the whole draft on every suggestion. +function mentionStart(prompt: PromptInputV2Prompt, end: number) { + let position = 0 + let found = -1 + for (const part of prompt) { + if (!("content" in part)) continue + const start = position + position += part.content.length + if (start >= end) break + const index = part.content.lastIndexOf("@", end - start - 1) + if (index >= 0) found = start + index + } + return found +} + function insertMention( prompt: PromptInputV2Prompt, start: number,