perf(app): keep composer typing within the frame budget (#3450)

* perf(app): keep composer typing responsive

The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character.

* fix(app): preserve live composer state at actions

* fix(app): use live input for autocomplete clicks

* fix(app): keep submit activity atomic
This commit is contained in:
Mohamed Boudra
2026-08-16 16:05:29 +02:00
committed by GitHub
parent 42245d139a
commit 8c4e54eace
15 changed files with 1185 additions and 182 deletions
+18
View File
@@ -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
@@ -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);
@@ -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();
}
});
});
+1
View File
@@ -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",
@@ -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(`
<style>
html, body { margin: 0; width: 100%; height: 100%; }
textarea {
box-sizing: border-box;
width: 760px;
height: 180px;
margin: 400px 340px;
padding: 16px;
border: 1px solid #aaa;
resize: none;
font: 15px/1.4 system-ui, sans-serif;
}
</style>
<textarea aria-label="Plain textarea benchmark"></textarea>
`);
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();
@@ -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([]);
});
});
@@ -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<T> {
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;
}
}
+44 -4
View File
@@ -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<string>((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 () => {
+81 -54
View File
@@ -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 (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerContent {...props} />
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</ComposerKeyboardScopeProvider>
);
}
@@ -1238,6 +1248,7 @@ function ComposerContentImpl({
onPullRequestAdded: onGithubPrAutoAttach,
});
const [cursorIndex, setCursorIndex] = useState(0);
const cursorPublication = useMemo(() => new AfterPaintPublication<number>(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 */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
</RenderProfile>
<Combobox
options={githubSearchOptions}
value=""
+112 -55
View File
@@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
@@ -87,6 +88,17 @@ export interface AttachmentMenuItem {
icon?: React.ReactElement | null;
}
export interface ComposerInputSnapshot {
text: string;
selection: { start: number; end: number };
}
export interface ComposerKeyPressEvent {
key: string;
preventDefault: () => 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<MessageInputRef, MessageInputProps>(
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<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
value,
hasText: hasLiveText,
attachments,
hasExternalContent,
}),
@@ -1635,9 +1689,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
>
{attachmentSlot}
{/* Text input */}
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= 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()) : "",
})}
/>
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= 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()) : "",
})}
/>
</RenderProfile>
{/* Button row */}
<View style={styles.buttonRow}>
@@ -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(
<ComposerTextInput
text=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender with stale publication"
testID="composer-input"
/>,
);
});
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(
<ComposerTextInput
text="你"
@@ -132,7 +148,6 @@ describe("ComposerTextInput web IME composition", () => {
});
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(["你好"]);
@@ -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<ComposerTextInputHandle, ComposerTex
const inputRef = useRef<TextInput | null>(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<typeof setTimeout> | 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<ComposerTextInputHandle, ComposerTex
<ThemedTextInput
{...props}
ref={inputRef}
value={isComposing ? undefined : text}
defaultValue={text}
onChangeText={handleChangeText}
/>
);
@@ -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("")
+18 -9
View File
@@ -5,23 +5,32 @@ import {
type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> {
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<TKeyPressEvent extends AutocompleteKeyPressEvent> {
selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
onKeyPress: (event: TKeyPressEvent) => boolean;
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>,
): UseAutocompleteResult {
export function useAutocomplete<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
@@ -56,7 +65,7 @@ export function useAutocomplete<TOption>(
}, [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<TOption>(
: fallbackIndex;
const selectedOption = input.options[resolvedIndex];
if (selectedOption) {
input.onSelectOption(selectedOption);
input.onSelectOption(selectedOption, event);
}
return true;
}
@@ -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<ReturnType<typeof normalizeWorkspaceDescriptor>>;
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<void> {
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<void> {
});
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,