From 8c4e54eacefca3248b0502c1275cfc4c391b0ca0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 16:05:29 +0200 Subject: [PATCH] perf(app): keep composer typing within the frame budget (#3450) * perf(app): keep composer typing responsive The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character. * fix(app): preserve live composer state at actions * fix(app): use live input for autocomplete clicks * fix(app): keep submit activity atomic --- docs/development.md | 18 + .../e2e/browser/composer-autocomplete.spec.ts | 82 +++ .../new-workspace-composer-draft.spec.ts | 50 +- packages/app/package.json | 1 + .../app/scripts/profile-composer-typing.mjs | 542 ++++++++++++++++++ .../composer/after-paint-publication.test.ts | 73 +++ .../src/composer/after-paint-publication.ts | 60 ++ .../app/src/composer/draft/input-draft.ts | 48 +- packages/app/src/composer/index.tsx | 135 +++-- packages/app/src/composer/input/input.tsx | 167 ++++-- .../input/text-input.web.browser.test.tsx | 32 +- .../app/src/composer/input/text-input.web.tsx | 35 +- .../app/src/hooks/use-agent-autocomplete.ts | 80 ++- packages/app/src/hooks/use-autocomplete.ts | 27 +- .../app/src/screens/new-workspace-screen.tsx | 17 +- 15 files changed, 1185 insertions(+), 182 deletions(-) create mode 100644 packages/app/scripts/profile-composer-typing.mjs create mode 100644 packages/app/src/composer/after-paint-publication.test.ts create mode 100644 packages/app/src/composer/after-paint-publication.ts diff --git a/docs/development.md b/docs/development.md index 4981a6bf5..d7849bb3b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -242,6 +242,24 @@ focused invalidation capture; React Native's generated stacks make that mode hig Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis. +For sustained composer typing, run the paired composer-versus-textarea benchmark against a seeded +daemon: + +```bash +PASEO_PROFILE_APP_URL=http://localhost:19010 \ + npm run profile:composer-typing --workspace=@getpaseo/app +``` + +The benchmark opens the first workspace, preserves its existing draft, and dispatches 300 printable +keys at a fixed 16 ms cadence without waiting for each key to paint. It measures renderer +`keydown` to the next paint opportunity, verifies that every character survived, alternates the +composer and plain-textarea control across three runs, then restores the original draft. The report +includes percentiles, frame coalescing, input processing, React work, long tasks, slow samples, and +Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TYPING_CADENCE_MS`, +`PASEO_PROFILE_TYPING_REPEATS`, or `PASEO_PROFILE_WORKSPACE_DIGIT` to change the scenario. Optional +`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency +measurements so profiling overhead does not contaminate them. + ### Desktop macOS compositor watchdog macOS display sleep can leave Chromium's GPU-process display link — the vsync diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 37d9ed436..4119f7c69 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -544,6 +544,88 @@ test.describe("Composer autocomplete", () => { } }); + test("uses the live cursor when accepting a slash command", async ({ page }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/tdd"); + await expect( + page + .getByTestId("composer-autocomplete-popover") + .getByText("/tdd", { exact: true }) + .first(), + ).toBeVisible({ timeout: 30_000 }); + + await input.evaluate((element) => { + if (!(element instanceof HTMLTextAreaElement)) { + throw new Error("Composer input is not a textarea"); + } + element.setSelectionRange(0, 0); + element.dispatchEvent(new Event("select", { bubbles: true })); + element.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }), + ); + }); + + await expect(input).toHaveValue("/tdd"); + const selectionStart = await input.evaluate( + (element) => (element as HTMLTextAreaElement).selectionStart, + ); + expect(selectionStart).toBe(0); + } finally { + await agent.cleanup(); + } + }); + + test("uses the live input when clicking a slash command", async ({ page }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/td"); + const option = page + .getByTestId("composer-autocomplete-popover") + .getByText("/tdd", { exact: true }) + .first(); + await expect(option).toBeVisible({ timeout: 30_000 }); + + await option.evaluate((element) => { + const textarea = document.querySelector('textarea[aria-label="Message agent..."]'); + if (!(textarea instanceof HTMLTextAreaElement)) { + throw new Error("Composer input is not a textarea"); + } + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + if (!valueSetter) throw new Error("Textarea value setter is unavailable"); + valueSetter.call(textarea, "/tdd suffix"); + textarea.setSelectionRange(4, 4); + textarea.dispatchEvent( + new InputEvent("input", { + bubbles: true, + data: "d suffix", + inputType: "insertText", + }), + ); + (element as HTMLElement).click(); + }); + + await expect(input).toHaveValue("/tdd suffix"); + } finally { + await agent.cleanup(); + } + }); + test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => { await installListCommandsStub(page); const agent = await openReadyMockAgent(page); diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index 1a3795136..e7dbde3bd 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../support/fixtures"; +import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { @@ -85,4 +85,52 @@ test.describe("New workspace composer draft", () => { await project.cleanup(); } }); + + test("does not restore a submitted draft after deferred publication", async ({ page }) => { + const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: project.projectKey, + projectDisplayName: project.projectDisplayName, + }); + + const composer = page.getByRole("textbox", { name: "Message agent..." }); + const createButton = page.getByTestId("workspace-create-submit"); + await expect(composer).toBeEditable({ timeout: 30_000 }); + await expect(createButton).toBeEnabled({ timeout: 30_000 }); + + await composer.evaluate((element, draft) => { + if (!(element instanceof HTMLTextAreaElement)) { + throw new Error("Composer input is not a textarea"); + } + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + if (!valueSetter) throw new Error("Textarea value setter is unavailable"); + valueSetter.call(element, draft); + element.dispatchEvent( + new InputEvent("input", { + bubbles: true, + data: draft, + inputType: "insertText", + }), + ); + const button = document.querySelector('[data-testid="workspace-create-submit"]'); + if (!(button instanceof HTMLElement)) { + throw new Error("Create button is unavailable"); + } + button.click(); + }, DRAFT); + + await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceDraft(page, ""); + } finally { + await project.cleanup(); + } + }); }); diff --git a/packages/app/package.json b/packages/app/package.json index e2bd2a821..a7478325d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", + "profile:composer-typing": "node ./scripts/profile-composer-typing.mjs", "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", diff --git a/packages/app/scripts/profile-composer-typing.mjs b/packages/app/scripts/profile-composer-typing.mjs new file mode 100644 index 000000000..977005c65 --- /dev/null +++ b/packages/app/scripts/profile-composer-typing.mjs @@ -0,0 +1,542 @@ +import { writeFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010"; +const cadenceMs = Number(process.env.PASEO_PROFILE_TYPING_CADENCE_MS ?? 16); +const sampleCount = Number(process.env.PASEO_PROFILE_TYPING_KEYS ?? 300); +const repeatCount = Number(process.env.PASEO_PROFILE_TYPING_REPEATS ?? 3); +const workspaceDigit = process.env.PASEO_PROFILE_WORKSPACE_DIGIT ?? "1"; +const headless = process.env.PASEO_PROFILE_HEADLESS !== "0"; +const cpuProfilePath = process.env.PASEO_PROFILE_CPU_PATH; +const tracePath = process.env.PASEO_PROFILE_TRACE_PATH; +const alphabet = "abcdefghijklmnopqrstuvwxyz"; +const measuredText = Array.from( + { length: sampleCount }, + (_, index) => alphabet[index % alphabet.length], +).join(""); + +function round(value) { + return Math.round(value * 100) / 100; +} + +function percentile(values, ratio) { + const sorted = values.slice().sort((left, right) => left - right); + return sorted[Math.max(0, Math.ceil(sorted.length * ratio) - 1)] ?? 0; +} + +function standardDeviation(values) { + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length); +} + +function longestRun(values, predicate) { + let longest = 0; + let current = 0; + for (const value of values) { + current = predicate(value) ? current + 1 : 0; + longest = Math.max(longest, current); + } + return longest; +} + +function summarize(values) { + return { + p50Ms: round(percentile(values, 0.5)), + p95Ms: round(percentile(values, 0.95)), + p99Ms: round(percentile(values, 0.99)), + maxMs: round(Math.max(...values)), + stdevMs: round(standardDeviation(values)), + over16Ms: values.filter((value) => value > 16.67).length, + over24Ms: values.filter((value) => value > 24).length, + over32Ms: values.filter((value) => value > 32).length, + longestOver24Run: longestRun(values, (value) => value > 24), + }; +} + +function keyCode(character) { + const upper = character.toUpperCase(); + return { + code: `Key${upper}`, + windowsVirtualKeyCode: upper.charCodeAt(0), + }; +} + +async function installAppBootstrap(page) { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:keyboard-shortcut-overrides", + JSON.stringify({ "workspace-navigate-index-alt-digit-web": "Cmd+Digit" }), + ); + + const preserveRenderProfile = (original) => + function (state, unused, url) { + if (url === undefined || url === null) { + return Reflect.apply(original, this, [state, unused, url]); + } + const nextUrl = new URL(String(url), location.href); + nextUrl.searchParams.set("renderProfile", "1"); + return Reflect.apply(original, this, [ + state, + unused, + `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, + ]); + }; + + history.pushState = preserveRenderProfile(history.pushState); + history.replaceState = preserveRenderProfile(history.replaceState); + }); +} + +async function openComposer(page) { + await installAppBootstrap(page); + await page.goto(`${baseUrl}/?renderProfile=1`, { waitUntil: "commit" }); + await page.locator('[data-testid^="sidebar-workspace-row-"]').first().waitFor({ + timeout: 60_000, + }); + await page.keyboard.press(`Meta+${workspaceDigit}`); + const input = page.locator("[data-composer-input]").filter({ visible: true }).first(); + await input.waitFor({ state: "visible", timeout: 60_000 }); + await input.focus(); + await input.evaluate((element) => { + if (!(element instanceof HTMLTextAreaElement)) { + throw new Error("The visible composer input is not a textarea"); + } + element.setSelectionRange(element.value.length, element.value.length); + }); + return input; +} + +async function prepareTextareaControl(page) { + await page.setContent(` + + + `); + return page.getByRole("textbox", { name: "Plain textarea benchmark" }); +} + +async function installTypingProbe(page, target) { + await page.evaluate((probeTarget) => { + globalThis.__PASEO_TYPING_BENCHMARK_CLEANUP__?.(); + const element = + probeTarget === "composer" + ? [...document.querySelectorAll("[data-composer-input]")].find( + (candidate) => candidate.getClientRects().length > 0, + ) + : document.querySelector('textarea[aria-label="Plain textarea benchmark"]'); + if (!(element instanceof HTMLTextAreaElement)) { + throw new Error(`Typing target ${probeTarget} was not found`); + } + + let sequence = 0; + let frameScheduled = false; + let paintedFrames = 0; + const pending = []; + const keydownAt = []; + const inputAt = []; + const paintedAt = []; + const longTasks = []; + const eventTimings = []; + const longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTasks.push({ startTime: entry.startTime, duration: entry.duration }); + } + }); + longTaskObserver.observe({ type: "longtask", buffered: true }); + const eventObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + eventTimings.push({ + name: entry.name, + startTime: entry.startTime, + duration: entry.duration, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + }); + } + }); + eventObserver.observe({ type: "event", buffered: true, durationThreshold: 0 }); + + const onKeydown = (event) => { + if ( + event.target !== element || + event.key.length !== 1 || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + return; + } + keydownAt[sequence] = performance.now(); + pending.push(sequence); + sequence += 1; + }; + const onInput = () => { + const changedAt = performance.now(); + for (const pendingSequence of pending) { + inputAt[pendingSequence] ??= changedAt; + } + if (frameScheduled || pending.length === 0) return; + frameScheduled = true; + requestAnimationFrame(() => { + frameScheduled = false; + paintedFrames += 1; + const frameAt = performance.now(); + for (const completedSequence of pending.splice(0)) { + paintedAt[completedSequence] = frameAt; + } + }); + }; + element.addEventListener("keydown", onKeydown, true); + element.addEventListener("input", onInput); + globalThis.__PASEO_TYPING_BENCHMARK__ = { + get receivedKeys() { + return sequence; + }, + get paintedFrames() { + return paintedFrames; + }, + keydownAt, + inputAt, + paintedAt, + longTasks, + eventTimings, + timeOrigin: performance.timeOrigin, + }; + globalThis.__PASEO_TYPING_BENCHMARK_CLEANUP__ = () => { + element.removeEventListener("keydown", onKeydown, true); + element.removeEventListener("input", onInput); + longTaskObserver.disconnect(); + eventObserver.disconnect(); + }; + }, target); +} + +function groupReactCommits(samples) { + const commits = new Map(); + for (const sample of samples) { + const commit = commits.get(sample.commitTime) ?? { + at: sample.commitTime, + rootDurationMs: null, + components: [], + }; + if (sample.id === "WorkspaceDeck") { + commit.rootDurationMs = sample.actualDuration; + } + commit.components.push({ id: sample.id, durationMs: sample.actualDuration }); + commits.set(sample.commitTime, commit); + } + return [...commits.values()].sort((left, right) => left.at - right.at); +} + +function summarizeReactComponents(samples) { + const totals = new Map(); + for (const sample of samples) { + const current = totals.get(sample.id) ?? { id: sample.id, commits: 0, durationMs: 0 }; + current.commits += 1; + current.durationMs += sample.actualDuration; + totals.set(sample.id, current); + } + for (const component of totals.values()) { + component.durationMs = round(component.durationMs); + } + return [...totals.values()].sort((left, right) => right.durationMs - left.durationMs); +} + +function summarizeReactCommitShapes(commits) { + const shapes = new Map(); + for (const commit of commits) { + const ids = commit.components + .filter((component) => + ["ComposerContent", "MessageInput", "ComposerTextSurface"].includes(component.id), + ) + .map((component) => component.id) + .sort(); + const key = ids.join(" + ") || "outside composer"; + const current = shapes.get(key) ?? { shape: key, commits: 0, rootDurationMs: 0 }; + current.commits += 1; + current.rootDurationMs += commit.rootDurationMs ?? 0; + shapes.set(key, current); + } + for (const shape of shapes.values()) { + shape.rootDurationMs = round(shape.rootDurationMs); + } + return [...shapes.values()].sort((left, right) => right.rootDurationMs - left.rootDurationMs); +} + +async function dispatchMeasuredText(page, text) { + const session = await page.context().newCDPSession(page); + const dispatchedAt = Array.from({ length: text.length }); + const scheduledAt = Array.from({ length: text.length }); + const now = () => performance.timeOrigin + performance.now(); + const startedAt = now() + 100; + const dispatches = []; + + await Promise.all( + Array.from(text, async (character, sequence) => { + const dueAt = startedAt + sequence * cadenceMs; + scheduledAt[sequence] = dueAt; + const waitMs = dueAt - now(); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + dispatchedAt[sequence] = now(); + const key = keyCode(character); + dispatches.push( + session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: character, + text: character, + unmodifiedText: character, + ...key, + }), + session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: character, + ...key, + }), + ); + }), + ); + await Promise.all(dispatches); + await session.detach(); + return { dispatchedAt, scheduledAt }; +} + +async function waitForMeasurement(page, expectedKeys) { + await page.waitForFunction( + (count) => globalThis.__PASEO_TYPING_BENCHMARK__?.receivedKeys === count, + expectedKeys, + { timeout: 15_000 }, + ); + await page.waitForFunction( + (count) => + globalThis.__PASEO_TYPING_BENCHMARK__?.paintedAt.filter(Number.isFinite).length === count, + expectedKeys, + { timeout: 15_000 }, + ); +} + +async function measureTarget(page, input, target, originalText) { + await input.fill(originalText); + await input.focus(); + await input.evaluate((element) => { + element.setSelectionRange(element.value.length, element.value.length); + }); + await installTypingProbe(page, target); + await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.()); + const { dispatchedAt, scheduledAt } = await dispatchMeasuredText(page, measuredText); + await waitForMeasurement(page, measuredText.length); + + const diagnostics = await page.evaluate(() => ({ + state: globalThis.__PASEO_TYPING_BENCHMARK__, + commits: globalThis.__PASEO_RENDER_PROFILE__ ?? [], + value: document.activeElement?.value ?? null, + })); + if (diagnostics.value !== `${originalText}${measuredText}`) { + throw new Error( + `Typing target lost input: expected ${measuredText.length} appended characters`, + ); + } + + const state = diagnostics.state; + const latencies = state.paintedAt.map((paintedAt, index) => paintedAt - state.keydownAt[index]); + const processing = state.inputAt.map((inputAt, index) => inputAt - state.keydownAt[index]); + const dispatchLateness = dispatchedAt.map((dispatched, index) => dispatched - scheduledAt[index]); + const commits = groupReactCommits(diagnostics.commits); + const slowSamples = latencies + .map((latencyMs, sequence) => { + const startedAt = state.keydownAt[sequence]; + const completedAt = state.paintedAt[sequence]; + const attributedCommits = commits.filter( + (commit) => commit.at >= startedAt && commit.at <= completedAt, + ); + return { + sequence, + latencyMs: round(latencyMs), + inputMs: round(processing[sequence]), + reactCommits: attributedCommits.length, + rootReactDurationMs: round( + attributedCommits.reduce((sum, commit) => sum + (commit.rootDurationMs ?? 0), 0), + ), + components: [ + ...new Set( + attributedCommits.flatMap((commit) => + commit.components + .filter((component) => component.durationMs >= 0.5) + .map((component) => component.id), + ), + ), + ], + }; + }) + .filter((sample) => sample.latencyMs > 16.67) + .sort((left, right) => right.latencyMs - left.latencyMs) + .slice(0, 20); + const rootCommits = commits.filter((commit) => commit.rootDurationMs !== null); + + return { + target, + sampleCount: measuredText.length, + cadenceMs, + ...summarize(latencies), + inputProcessing: summarize(processing), + p95DispatchLatenessMs: round(percentile(dispatchLateness, 0.95)), + maxDispatchLatenessMs: round(Math.max(...dispatchLateness)), + coalescedFrames: measuredText.length - state.paintedFrames, + reactCommitCount: rootCommits.length, + rootReactDurationMs: round(rootCommits.reduce((sum, commit) => sum + commit.rootDurationMs, 0)), + reactComponents: summarizeReactComponents(diagnostics.commits), + reactCommitShapes: summarizeReactCommitShapes(commits), + longTasks: state.longTasks, + slowSamples, + }; +} + +async function warmTarget(page, input, originalText) { + await input.fill(originalText); + await input.focus(); + await input.evaluate((element) => { + element.setSelectionRange(element.value.length, element.value.length); + }); + await page.keyboard.type("warmupwarmupwarmupwarmupwarmup", { delay: 5 }); + await input.fill(originalText); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); +} + +async function captureCpuProfile(page, input, originalText) { + const session = await page.context().newCDPSession(page); + await input.fill(originalText); + await input.focus(); + await installTypingProbe(page, "composer"); + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 100 }); + await session.send("Profiler.start"); + try { + await dispatchMeasuredText(page, measuredText); + await waitForMeasurement(page, measuredText.length); + } finally { + const { profile } = await session.send("Profiler.stop"); + await session.send("Profiler.disable"); + await session.detach(); + await writeFile(cpuProfilePath, JSON.stringify(profile)); + } +} + +async function readCdpStream(session, handle) { + const chunks = []; + while (true) { + const result = await session.send("IO.read", { handle }); + chunks.push( + result.base64Encoded ? Buffer.from(result.data, "base64") : Buffer.from(result.data), + ); + if (result.eof) break; + } + await session.send("IO.close", { handle }); + return Buffer.concat(chunks); +} + +async function captureTrace(page, input, originalText) { + const session = await page.context().newCDPSession(page); + const tracingComplete = new Promise((resolve) => + session.once("Tracing.tracingComplete", resolve), + ); + await input.fill(originalText); + await input.focus(); + await installTypingProbe(page, "composer"); + await session.send("Tracing.start", { + categories: [ + "-*", + "toplevel", + "devtools.timeline", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "disabled-by-default-v8.cpu_profiler", + "disabled-by-default-v8.cpu_profiler.hires", + ].join(","), + options: "sampling-frequency=10000", + transferMode: "ReturnAsStream", + }); + try { + await dispatchMeasuredText(page, measuredText); + await waitForMeasurement(page, measuredText.length); + } finally { + await session.send("Tracing.end"); + const completed = await tracingComplete; + const trace = await readCdpStream(session, completed.stream); + await writeFile(tracePath, trace); + await session.detach(); + } +} + +async function main() { + const browser = await chromium.launch({ headless }); + const composerPage = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const textareaPage = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + let composerInput; + let originalText; + const runs = []; + + try { + composerInput = await openComposer(composerPage); + originalText = await composerInput.inputValue(); + const textareaInput = await prepareTextareaControl(textareaPage); + await warmTarget(composerPage, composerInput, originalText); + await warmTarget(textareaPage, textareaInput, ""); + + for (let repeat = 0; repeat < repeatCount; repeat += 1) { + if (repeat % 2 === 0) { + runs.push(await measureTarget(composerPage, composerInput, "composer", originalText)); + runs.push(await measureTarget(textareaPage, textareaInput, "textarea", "")); + } else { + runs.push(await measureTarget(textareaPage, textareaInput, "textarea", "")); + runs.push(await measureTarget(composerPage, composerInput, "composer", originalText)); + } + } + + if (cpuProfilePath) { + await captureCpuProfile(composerPage, composerInput, originalText); + } + if (tracePath) { + await captureTrace(composerPage, composerInput, originalText); + } + + console.log( + JSON.stringify( + { + appUrl: baseUrl, + cadenceMs, + sampleCount, + repeatCount, + cpuProfilePath: cpuProfilePath ?? null, + tracePath: tracePath ?? null, + runs, + }, + null, + 2, + ), + ); + } finally { + if (composerInput && originalText !== undefined) { + await composerInput.fill(originalText).catch(() => undefined); + } + await browser.close(); + } +} + +await main(); diff --git a/packages/app/src/composer/after-paint-publication.test.ts b/packages/app/src/composer/after-paint-publication.test.ts new file mode 100644 index 000000000..ca0eb6687 --- /dev/null +++ b/packages/app/src/composer/after-paint-publication.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { AfterPaintPublication, type AfterPaintScheduler } from "./after-paint-publication"; + +function createManualScheduler(): AfterPaintScheduler & { + run: () => void; + scheduledCount: () => number; +} { + let callbacks: Array<() => void> = []; + return { + schedule: (callback) => { + callbacks.push(callback); + return () => { + callbacks = callbacks.filter((candidate) => candidate !== callback); + }; + }, + run: () => { + const scheduled = callbacks; + callbacks = []; + for (const callback of scheduled) callback(); + }, + scheduledCount: () => callbacks.length, + }; +} + +describe("AfterPaintPublication", () => { + it("publishes the latest staged value once after paint", () => { + const scheduler = createManualScheduler(); + const published: string[] = []; + const publication = new AfterPaintPublication( + (value: string) => published.push(value), + scheduler, + ); + + publication.stage("a"); + publication.stage("ab"); + publication.stage("abc"); + + expect(scheduler.scheduledCount()).toBe(1); + expect(published).toEqual([]); + scheduler.run(); + expect(published).toEqual(["abc"]); + }); + + it("flushes synchronously and cancels the scheduled publication", () => { + const scheduler = createManualScheduler(); + const published: string[] = []; + const publication = new AfterPaintPublication( + (value: string) => published.push(value), + scheduler, + ); + + publication.stage("draft"); + publication.flush(); + scheduler.run(); + + expect(published).toEqual(["draft"]); + }); + + it("cancels pending text before a draft is cleared", () => { + const scheduler = createManualScheduler(); + const published: string[] = []; + const publication = new AfterPaintPublication( + (value: string) => published.push(value), + scheduler, + ); + + publication.stage("sent text"); + publication.cancel(); + scheduler.run(); + + expect(published).toEqual([]); + }); +}); diff --git a/packages/app/src/composer/after-paint-publication.ts b/packages/app/src/composer/after-paint-publication.ts new file mode 100644 index 000000000..672b50648 --- /dev/null +++ b/packages/app/src/composer/after-paint-publication.ts @@ -0,0 +1,60 @@ +export interface AfterPaintScheduler { + schedule: (callback: () => void) => () => void; +} + +export const browserAfterPaintScheduler: AfterPaintScheduler = { + schedule: (callback) => { + if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") { + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) callback(); + }); + return () => { + cancelled = true; + }; + } + + let timeoutId: number | null = null; + const frameId = window.requestAnimationFrame(() => { + timeoutId = window.setTimeout(callback, 0); + }); + return () => { + window.cancelAnimationFrame(frameId); + if (timeoutId !== null) window.clearTimeout(timeoutId); + }; + }, +}; + +export class AfterPaintPublication { + private pending: T | null = null; + private cancelScheduled: (() => void) | null = null; + + constructor( + private readonly publish: (value: T) => void, + private readonly scheduler: AfterPaintScheduler = browserAfterPaintScheduler, + ) {} + + stage(value: T): void { + this.pending = value; + if (this.cancelScheduled) return; + this.cancelScheduled = this.scheduler.schedule(() => { + this.cancelScheduled = null; + this.flush(); + }); + } + + flush(): void { + if (this.pending === null) return; + this.cancelScheduled?.(); + this.cancelScheduled = null; + const value = this.pending; + this.pending = null; + this.publish(value); + } + + cancel(): void { + this.cancelScheduled?.(); + this.cancelScheduled = null; + this.pending = null; + } +} diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index a579064e0..78587c95e 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -22,6 +22,8 @@ import { } from "@/provider-selection/provider-selection"; import { useDraftStore } from "@/stores/draft-store"; import { toDraftInputIfReady } from "@/stores/draft-store/state"; +import { AfterPaintPublication } from "@/composer/after-paint-publication"; +import { isWeb } from "@/constants/platform"; type AttachmentUpdater = | UserComposerAttachment[] @@ -110,19 +112,32 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [draftKey], ); + const textPublication = useMemo( + () => + new AfterPaintPublication((nextText) => { + saveDraft((current) => ({ ...current, text: nextText })); + }), + [saveDraft], + ); + const editText = useCallback( (nextText: string) => { - saveDraft((current) => ({ ...current, text: nextText })); + if (isWeb) { + textPublication.stage(nextText); + } else { + saveDraft((current) => ({ ...current, text: nextText })); + } }, - [saveDraft], + [saveDraft, textPublication], ); const replaceText = useCallback( (nextText: string) => { + textPublication.cancel(); saveDraft((current) => ({ ...current, text: nextText })); setTextReplacementRevision((revision) => revision + 1); }, - [saveDraft], + [saveDraft, textPublication], ); const setAttachments = useCallback( @@ -137,11 +152,36 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const clear = useCallback( (lifecycle: "sent" | "abandoned") => { + textPublication.cancel(); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); }, - [draftKey], + [draftKey, textPublication], ); + useEffect(() => { + const flushWhenHidden = () => { + if (document.visibilityState === "hidden") textPublication.flush(); + }; + const flush = () => textPublication.flush(); + const canListenForPageHide = + isWeb && typeof window !== "undefined" && typeof window.addEventListener === "function"; + if (isWeb && typeof document !== "undefined") { + document.addEventListener("visibilitychange", flushWhenHidden); + } + if (canListenForPageHide) { + window.addEventListener("pagehide", flush); + } + return () => { + if (isWeb && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", flushWhenHidden); + } + if (canListenForPageHide) { + window.removeEventListener("pagehide", flush); + } + textPublication.flush(); + }; + }, [textPublication]); + useEffect(() => { let cancelled = false; void (async () => { diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 635fe6b10..3516d10b5 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -47,7 +47,12 @@ import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-s import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; -import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input"; +import { + MessageInput, + type AttachmentMenuItem, + type ComposerKeyPressEvent, + type MessageInputRef, +} from "./input/input"; import type { ImageAttachment, MessagePayload } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; @@ -76,6 +81,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { AutocompletePopover } from "@/components/ui/autocomplete-popover"; +import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; import { useHostRuntimeAgentDirectoryStatus, @@ -98,6 +104,8 @@ import { submitAgentInput } from "@/composer/submit"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; +import { RenderProfile } from "@/utils/render-profiler"; +import { AfterPaintPublication } from "@/composer/after-paint-publication"; import { isWeb, isNative } from "@/constants/platform"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { @@ -1115,7 +1123,9 @@ function ComposerVoiceModeButton({ export function Composer({ isPaneFocused, ...props }: ComposerProps) { return ( - + + + ); } @@ -1238,6 +1248,7 @@ function ComposerContentImpl({ onPullRequestAdded: onGithubPrAutoAttach, }); const [cursorIndex, setCursorIndex] = useState(0); + const cursorPublication = useMemo(() => new AfterPaintPublication(setCursorIndex), []); const [isProcessing, setIsProcessing] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); @@ -1322,12 +1333,20 @@ function ComposerContentImpl({ }); const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; + const selectAutocompleteOption = autocomplete.onSelectOption; + const handleAutocompleteSelect = useCallback( + (option: AutocompleteOption) => + selectAutocompleteOption(option, messageInputRef.current?.getInputSnapshot()), + [selectAutocompleteOption], + ); // Clear send error when user edits the input useEffect(() => { setCursorIndex((current) => Math.min(current, userInput.length)); }, [userInput.length]); + useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); + const { pickImages } = useImageAttachmentPicker(); const { pickFiles } = useFilePicker(); const agentIdRef = useRef(agentId); @@ -1835,8 +1854,7 @@ function ComposerContentImpl({ // Handle keyboard navigation for command autocomplete. const handleCommandKeyPress = useCallback( - (event: { key: string; preventDefault: () => void }) => - autocompleteOnKeyPressRef.current(event), + (event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event), [], ); @@ -2084,9 +2102,16 @@ function ComposerContentImpl({ attachButtonRef.current = node; }, []); - const handleSelectionChange = useCallback((selection: { start: number; end: number }) => { - setCursorIndex(selection.start); - }, []); + const handleSelectionChange = useCallback( + (selection: { start: number; end: number }) => { + if (isWeb) { + cursorPublication.stage(selection.start); + } else { + setCursorIndex(selection.start); + } + }, + [cursorPublication], + ); const handleFocusChange = useCallback( (focused: boolean) => { @@ -2236,7 +2261,7 @@ function ComposerContentImpl({ anchorRef={messageInputContainerRef} options={autocomplete.options} selectedIndex={autocomplete.selectedIndex} - onSelect={autocomplete.onSelectOption} + onSelect={handleAutocompleteSelect} isLoading={autocomplete.isLoading} errorMessage={autocomplete.errorMessage} loadingText={autocomplete.loadingText} @@ -2244,52 +2269,54 @@ function ComposerContentImpl({ /> {/* MessageInput handles everything: text, dictation, attachments, all buttons */} - + + + void; + input: ComposerInputSnapshot; +} + export interface MessageInputProps { value: string; onChangeText: (text: string) => void; @@ -137,7 +149,7 @@ export interface MessageInputProps { /** Optional handler used when submit button is in loading state. */ onSubmitLoadingPress?: () => void; /** Intercept key press events before default handling. Return true to prevent default. */ - onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; + onKeyPress?: (event: ComposerKeyPressEvent) => boolean; /** Reports cursor selection updates from the underlying input. */ onSelectionChange?: (selection: { start: number; end: number }) => void; onFocusChange?: (focused: boolean) => void; @@ -160,6 +172,7 @@ export interface MessageInputRef { focus: () => void; blur: () => void; getText: () => string; + getInputSnapshot: () => ComposerInputSnapshot; replaceText: (text: string, selection?: { start: number; end: number }) => void; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** @@ -361,7 +374,8 @@ function SendButtonContent({ } interface DesktopKeyPressContext { - onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; + onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; + input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; @@ -382,6 +396,7 @@ function handleDesktopKeyPressImpl( const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), + input: ctx.input, }); if (handled) return; } @@ -775,11 +790,11 @@ function SendButtonTooltip({ type PrimaryActionKind = "send" | "active" | "none"; function hasSendableComposerContent(input: { - value: string; + hasText: boolean; attachments: readonly ComposerAttachment[]; hasExternalContent: boolean; }): boolean { - return input.value.trim().length > 0 || input.attachments.length > 0 || input.hasExternalContent; + return input.hasText || input.attachments.length > 0 || input.hasExternalContent; } function resolvePrimaryActionKind(input: { @@ -997,6 +1012,18 @@ function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHa return null; } +function getComposerInputSnapshot( + current: ComposerTextInputHandle | null, + fallbackText: string, + fallbackSelection: ComposerInputSnapshot["selection"], +): ComposerInputSnapshot { + const text = current?.getText() ?? fallbackText; + const textArea = getWebTextAreaImpl(current); + const start = textArea?.selectionStart ?? fallbackSelection.start; + const end = textArea?.selectionEnd ?? fallbackSelection.end; + return { text, selection: { start, end } }; +} + interface SendButtonStateInput { disabled: boolean; isSubmitDisabled: boolean; @@ -1055,7 +1082,7 @@ interface ResolvedMessageInputProps { defaultSendBehavior: "interrupt" | "steer" | "queue"; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; - onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; + onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined; onFocusChange: ((focused: boolean) => void) | undefined; onHeightChange: ((height: number) => void) | undefined; @@ -1181,20 +1208,35 @@ export const MessageInput = forwardRef( const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isInputFocused, setIsInputFocused] = useState(false); + // Web text is DOM-owned between deferred draft publications. The action button only needs + // this boundary, so publish empty/non-empty transitions without rerendering for every key. + const initialHasLiveText = value.trim().length > 0; + const [hasLiveText, setHasLiveText] = useState(initialHasLiveText); + const hasLiveTextRef = useRef(initialHasLiveText); const rootRef = useRef(null); const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); const valueRef = useRef(value); + const selectionRef = useRef({ start: value.length, end: value.length }); const appliedTextReplacementKeyRef = useRef(textReplacementKey); + const updateLiveTextPresence = useCallback((text: string) => { + const nextHasLiveText = text.trim().length > 0; + if (hasLiveTextRef.current === nextHasLiveText) return; + hasLiveTextRef.current = nextHasLiveText; + setHasLiveText(nextHasLiveText); + }, []); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { valueRef.current = nextText; + updateLiveTextPresence(nextText); + selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; textInputRef.current?.replaceText(nextText, selection); onChangeText(nextText); }, - [onChangeText], + [onChangeText, updateLiveTextPresence], ); useImperativeHandle(ref, () => ({ @@ -1205,6 +1247,8 @@ export const MessageInput = forwardRef( textInputRef.current?.blur(); }, getText: () => textInputRef.current?.getText() ?? valueRef.current, + getInputSnapshot: () => + getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current), replaceText, runKeyboardAction: (action) => runMessageInputKeyboardAction(action, { @@ -1240,8 +1284,9 @@ export const MessageInput = forwardRef( if (appliedTextReplacementKeyRef.current === textReplacementKey) return; appliedTextReplacementKeyRef.current = textReplacementKey; valueRef.current = value; + updateLiveTextPresence(value); textInputRef.current?.replaceText(value); - }, [textReplacementKey, value]); + }, [textReplacementKey, updateLiveTextPresence, value]); useEffect(() => { return () => { @@ -1443,30 +1488,33 @@ export const MessageInput = forwardRef( onHeightChange?.(MIN_INPUT_HEIGHT); }, [onHeightChange]); - const handleSendMessage = useCallback( - () => - sendMessageImpl({ - value: textInputRef.current?.getText() ?? valueRef.current, - attachments, - hasExternalContent, - allowEmptySubmit, - cwd, - isAgentRunning, - onSubmit, - onMinimizeHeight: minimizeInputHeight, - preserveHeightOnSubmit, - }), - [ - allowEmptySubmit, + const handleSendMessage = useCallback(() => { + const liveValue = textInputRef.current?.getText() ?? valueRef.current; + if (!preserveHeightOnSubmit) { + updateLiveTextPresence(""); + } + sendMessageImpl({ + value: liveValue, attachments, - cwd, - onSubmit, - isAgentRunning, hasExternalContent, - minimizeInputHeight, + allowEmptySubmit, + cwd, + isAgentRunning, + onSubmit, + onMinimizeHeight: minimizeInputHeight, preserveHeightOnSubmit, - ], - ); + }); + }, [ + allowEmptySubmit, + attachments, + cwd, + onSubmit, + isAgentRunning, + hasExternalContent, + minimizeInputHeight, + preserveHeightOnSubmit, + updateLiveTextPresence, + ]); const handleQueueMessage = useCallback( () => @@ -1558,6 +1606,7 @@ export const MessageInput = forwardRef( (event: NativeSyntheticEvent) => { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; + selectionRef.current = { start, end }; onSelectionChangeCallback?.({ start, end }); }, [onSelectionChangeCallback], @@ -1570,6 +1619,11 @@ export const MessageInput = forwardRef( if (!shouldHandleWebKeyPress) return; handleDesktopKeyPressImpl(event, { onKeyPressCallback, + input: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ), submitOnEnter: shouldSubmitOnEnter, isAgentRunning, onQueue, @@ -1583,7 +1637,7 @@ export const MessageInput = forwardRef( const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ - value, + hasText: hasLiveText, attachments, hasExternalContent, }), @@ -1635,9 +1689,10 @@ export const MessageInput = forwardRef( const handleInputChange = useCallback( (nextValue: string) => { valueRef.current = nextValue; + updateLiveTextPresence(nextValue); onChangeText(nextValue); }, - [onChangeText], + [onChangeText, updateLiveTextPresence], ); const handleInputFocus = useCallback(() => { @@ -1762,31 +1817,33 @@ export const MessageInput = forwardRef( > {attachmentSlot} {/* Text input */} - = maxInputHeight : true} - autoFocus={false} - onContentSizeChange={handleContentSizeChange} - onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} - onSelectionChange={handleSelectionChange} - onPasteImages={onPasteImages} - onPasteError={handlePasteError} - focusHintVisible={isWeb && !isInputFocused && !value} - focusInputKeys={focusInputKeys} - focusHintLabel={t("composer.input.focusHint", { - shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", - })} - /> + + = maxInputHeight : true} + autoFocus={false} + onContentSizeChange={handleContentSizeChange} + onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} + onSelectionChange={handleSelectionChange} + onPasteImages={onPasteImages} + onPasteError={handlePasteError} + focusHintVisible={isWeb && !isInputFocused && !value} + focusInputKeys={focusInputKeys} + focusHintLabel={t("composer.input.focusHint", { + shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", + })} + /> + {/* Button row */} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 10fc2696a..82fa0dc17 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -1,6 +1,6 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { ComposerTextInput } from "./text-input.web"; interface MountedInput { @@ -73,12 +73,31 @@ afterEach(() => { act(() => mounted.root.unmount()); mounted.container.remove(); } - vi.useRealTimers(); }); describe("ComposerTextInput web IME composition", () => { + it("keeps locally typed text when its parent rerenders with a stale value", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + + act(() => { + typeFromIme(mounted.textarea, "locally typed"); + mounted.root.render( + , + ); + }); + + expect(mounted.textarea.value).toBe("locally typed"); + expect(recorder.changes).toEqual(["locally typed"]); + }); + it("keeps the DOM-owned candidate during composition and reports the committed text", () => { - vi.useFakeTimers(); const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); @@ -100,14 +119,12 @@ describe("ComposerTextInput web IME composition", () => { act(() => { dispatchComposition(mounted.textarea, "compositionend"); - vi.runAllTimers(); }); expect(recorder.changes).toEqual(["你好"]); }); - it("does not let a previous composition-end timer take ownership from a new composition", () => { - vi.useFakeTimers(); + it("keeps the latest candidate across consecutive compositions", () => { const mounted = mountInput(ignoreTextChange); act(() => { @@ -116,7 +133,6 @@ describe("ComposerTextInput web IME composition", () => { dispatchComposition(mounted.textarea, "compositionend"); dispatchComposition(mounted.textarea, "compositionstart"); mounted.textarea.value = "你好"; - vi.runAllTimers(); mounted.root.render( { }); it("does not report committed text twice when the input event already reported it", () => { - vi.useFakeTimers(); const changes: string[] = []; const mounted = mountInput((text) => changes.push(text)); @@ -140,7 +155,6 @@ describe("ComposerTextInput web IME composition", () => { dispatchComposition(mounted.textarea, "compositionstart"); typeFromIme(mounted.textarea, "你好"); dispatchComposition(mounted.textarea, "compositionend"); - vi.runAllTimers(); }); expect(changes).toEqual(["你好"]); diff --git a/packages/app/src/composer/input/text-input.web.tsx b/packages/app/src/composer/input/text-input.web.tsx index 5b46172ee..d6559dc2d 100644 --- a/packages/app/src/composer/input/text-input.web.tsx +++ b/packages/app/src/composer/input/text-input.web.tsx @@ -1,11 +1,4 @@ -import React, { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { TextInput } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; @@ -13,11 +6,8 @@ import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-inp interface WebTextInputElement extends TextInput { value?: string; setSelectionRange?: (start: number, end: number) => void; - addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void; - removeEventListener: ( - type: "compositionstart" | "compositionend", - listener: EventListener, - ) => void; + addEventListener: (type: "compositionend", listener: EventListener) => void; + removeEventListener: (type: "compositionend", listener: EventListener) => void; } const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ @@ -32,39 +22,22 @@ export const ComposerTextInput = forwardRef(null); const textRef = useRef(text); const onChangeTextRef = useRef(onChangeText); - const [isComposing, setIsComposing] = useState(false); - if (!isComposing) textRef.current = text; onChangeTextRef.current = onChangeText; useEffect(() => { const input = inputRef.current as WebTextInputElement | null; if (!input) return; - let compositionEndTimer: ReturnType | null = null; - const startComposition = () => { - if (compositionEndTimer !== null) { - clearTimeout(compositionEndTimer); - compositionEndTimer = null; - } - setIsComposing(true); - }; const endComposition = () => { const nextText = input.value ?? ""; if (nextText !== textRef.current) { textRef.current = nextText; onChangeTextRef.current(nextText); } - compositionEndTimer = setTimeout(() => { - compositionEndTimer = null; - setIsComposing(false); - }, 0); }; - input.addEventListener("compositionstart", startComposition); input.addEventListener("compositionend", endComposition); return () => { - if (compositionEndTimer !== null) clearTimeout(compositionEndTimer); - input.removeEventListener("compositionstart", startComposition); input.removeEventListener("compositionend", endComposition); }; }, []); @@ -99,7 +72,7 @@ export const ComposerTextInput = forwardRef ); diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 6ebbd2fe1..5e0d3a12b 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -38,6 +38,17 @@ interface UseAgentAutocompleteInput { canExecuteClientSlashCommand?: boolean; } +interface AgentAutocompleteKeyPressEvent { + key: string; + preventDefault: () => void; + input: AgentAutocompleteInputSnapshot; +} + +interface AgentAutocompleteInputSnapshot { + text: string; + selection: { start: number; end: number }; +} + type AgentAutocompleteOption = | (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand }) | (AutocompleteOption & { type: "provider_command" }) @@ -55,8 +66,38 @@ interface AgentAutocompleteResult { errorMessage?: string; loadingText: string; emptyText: string; - onSelectOption: (option: AutocompleteOption) => void; - onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; + onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void; + onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean; +} + +interface AgentAutocompleteSnapshot { + text: string; + slashCommand: SlashCommandRange | null; + fileMention: FileMentionRange | null; +} + +function resolveAgentAutocompleteSnapshot(input: { + input?: AgentAutocompleteInputSnapshot; + userInput: string; + cursorIndex: number; + activeSlashCommand: SlashCommandRange | null; + activeFileMention: FileMentionRange | null; +}): AgentAutocompleteSnapshot { + if (!input.input) { + return { + text: input.userInput, + slashCommand: input.activeSlashCommand, + fileMention: input.activeFileMention, + }; + } + + const text = input.input.text; + const cursorIndex = input.input.selection.start; + return { + text, + slashCommand: findActiveSlashCommand({ text, cursorIndex }), + fileMention: findActiveFileMention({ text, cursorIndex }), + }; } interface DirectorySuggestionEntry { @@ -426,8 +467,18 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut ); const onSelectOption = useCallback( - (option: AutocompleteOption) => { + (option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => { const selected = option as AgentAutocompleteOption; + const current = resolveAgentAutocompleteSnapshot({ + input: snapshot, + userInput, + cursorIndex, + activeSlashCommand, + activeFileMention, + }); + const selectedIsCommand = + selected.type === "client_command" || selected.type === "provider_command"; + if (snapshot && selectedIsCommand && !current.slashCommand) return; if ( selected.type === "client_command" && selected.command.execution === "immediate" && @@ -438,16 +489,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return; } - if (selected.type === "client_command" || selected.type === "provider_command") { - if (!activeSlashCommand) { + if (selectedIsCommand) { + if (!current.slashCommand) { setUserInput(`/${selected.id} `); onAutocompleteApplied?.(); return; } const nextInput = applySlashCommandReplacement({ - text: userInput, - command: activeSlashCommand, + text: current.text, + command: current.slashCommand, commandName: selected.id, }); setUserInput(nextInput); @@ -455,9 +506,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return; } + if (!current.fileMention) return; const nextInput = applyFileMentionReplacement({ - text: userInput, - mention: selected.mention, + text: current.text, + mention: current.fileMention, relativePath: selected.entryPath, }); setUserInput(nextInput); @@ -469,15 +521,23 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut onClientSlashCommand, setUserInput, userInput, + cursorIndex, + activeFileMention, activeSlashCommand, ], ); + const selectOptionFromKeyPress = useCallback( + (option: AutocompleteOption, event?: AgentAutocompleteKeyPressEvent) => + onSelectOption(option, event?.input), + [onSelectOption], + ); + const { selectedIndex, onKeyPress } = useAutocomplete({ isVisible, options, query: mode === "command" ? commandFilterQuery : fileFilterQuery, - onSelectOption, + onSelectOption: selectOptionFromKeyPress, onEscape: mode === "command" && activeSlashCommand?.position === "start" ? () => setUserInput("") diff --git a/packages/app/src/hooks/use-autocomplete.ts b/packages/app/src/hooks/use-autocomplete.ts index 2a524b91c..b461c9555 100644 --- a/packages/app/src/hooks/use-autocomplete.ts +++ b/packages/app/src/hooks/use-autocomplete.ts @@ -5,23 +5,32 @@ import { type AutocompleteOptionsPosition, } from "@/components/ui/autocomplete-utils"; -interface UseAutocompleteInput { +interface AutocompleteKeyPressEvent { + key: string; + preventDefault: () => void; +} + +interface UseAutocompleteInput< + TOption, + TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent, +> { isVisible: boolean; options: readonly TOption[]; query: string; - onSelectOption: (option: TOption) => void; + onSelectOption: (option: TOption, event?: TKeyPressEvent) => void; onEscape?: () => void; optionsPosition?: AutocompleteOptionsPosition; } -interface UseAutocompleteResult { +interface UseAutocompleteResult { selectedIndex: number; - onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; + onKeyPress: (event: TKeyPressEvent) => boolean; } -export function useAutocomplete( - input: UseAutocompleteInput, -): UseAutocompleteResult { +export function useAutocomplete< + TOption, + TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent, +>(input: UseAutocompleteInput): UseAutocompleteResult { const [selectedIndex, setSelectedIndex] = useState(-1); const previousQueryRef = useRef(""); @@ -56,7 +65,7 @@ export function useAutocomplete( }, [input.isVisible, input.options.length, input.query, input.optionsPosition]); const onKeyPress = useCallback( - (event: { key: string; preventDefault: () => void }) => { + (event: TKeyPressEvent) => { if (!input.isVisible || input.options.length === 0) { return false; } @@ -97,7 +106,7 @@ export function useAutocomplete( : fallbackIndex; const selectedOption = input.options[resolvedIndex]; if (selectedOption) { - input.onSelectOption(selectedOption); + input.onSelectOption(selectedOption, event); } return true; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 2b75b1b2d..ec24e88d2 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -56,7 +56,6 @@ import { import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; -import { useDraftStore } from "@/stores/draft-store"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { @@ -744,7 +743,7 @@ function normalizeBranchDetails( interface SubmitDraftInput { serverId: string; - draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; @@ -855,7 +854,7 @@ interface CreateChatAgentInput { withInitialAgent: boolean; }) => Promise>; serverId: string; - draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; supportsForgeSearch: boolean; labels: { @@ -919,7 +918,7 @@ function buildComposerInitialValues(input: { } async function runCreateChatAgent(input: CreateChatAgentInput): Promise { - const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; + const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; const { text, attachments, cwd } = payload; if (!composerState) { throw new Error(input.labels.composerStateRequired); @@ -949,7 +948,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { }); submitWorkspaceDraft({ serverId, - draftKey, + clearDraft, draftId: input.draftId, initialSetup, workspaceId: ensuredWorkspace.id, @@ -1026,7 +1025,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { function submitWorkspaceDraft(input: SubmitDraftInput): void { const { serverId, - draftKey, + clearDraft, draftId: draftIdInput, workspaceId, workspaceDirectory, @@ -1078,12 +1077,12 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), allowEmptyText: true, }); + clearDraft("sent"); navigateToWorkspace({ serverId, workspaceId, target: submission.target, }); - useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); } function useNewWorkspaceHostSelector(input: { @@ -2071,7 +2070,7 @@ export function NewWorkspaceScreen({ forkDraftSetup, ensureWorkspace, serverId: selectedServerId, - draftKey, + clearDraft: chatDraft.clear, draftId, supportsForgeSearch, labels: { @@ -2089,7 +2088,7 @@ export function NewWorkspaceScreen({ [ composerState, draftId, - draftKey, + chatDraft.clear, ensureWorkspace, forkDraftSetup, launchTarget,