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,