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,