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,