Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-08-16 17:08:16 +02:00
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 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. 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 ### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync 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 }) => { test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page); await installListCommandsStub(page);
const agent = await openReadyMockAgent(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 { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { import {
@@ -85,4 +85,52 @@ test.describe("New workspace composer draft", () => {
await project.cleanup(); 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", "test:e2e:ui": "playwright test --ui",
"build": "npm run build:web", "build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform 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-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.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", "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"; } from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state"; import { toDraftInputIfReady } from "@/stores/draft-store/state";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { isWeb } from "@/constants/platform";
type AttachmentUpdater = type AttachmentUpdater =
| UserComposerAttachment[] | UserComposerAttachment[]
@@ -110,19 +112,32 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey], [draftKey],
); );
const textPublication = useMemo(
() =>
new AfterPaintPublication<string>((nextText) => {
saveDraft((current) => ({ ...current, text: nextText }));
}),
[saveDraft],
);
const editText = useCallback( const editText = useCallback(
(nextText: string) => { (nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText })); if (isWeb) {
textPublication.stage(nextText);
} else {
saveDraft((current) => ({ ...current, text: nextText }));
}
}, },
[saveDraft], [saveDraft, textPublication],
); );
const replaceText = useCallback( const replaceText = useCallback(
(nextText: string) => { (nextText: string) => {
textPublication.cancel();
saveDraft((current) => ({ ...current, text: nextText })); saveDraft((current) => ({ ...current, text: nextText }));
setTextReplacementRevision((revision) => revision + 1); setTextReplacementRevision((revision) => revision + 1);
}, },
[saveDraft], [saveDraft, textPublication],
); );
const setAttachments = useCallback( const setAttachments = useCallback(
@@ -137,11 +152,36 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const clear = useCallback( const clear = useCallback(
(lifecycle: "sent" | "abandoned") => { (lifecycle: "sent" | "abandoned") => {
textPublication.cancel();
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { 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 { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop"; import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types"; 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 type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; 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 { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover"; import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import { import {
useHostRuntimeAgentDirectoryStatus, useHostRuntimeAgentDirectoryStatus,
@@ -98,6 +104,8 @@ import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings"; 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 { isWeb, isNative } from "@/constants/platform";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { import type {
@@ -1115,7 +1123,9 @@ function ComposerVoiceModeButton({
export function Composer({ isPaneFocused, ...props }: ComposerProps) { export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return ( return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}> <ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerContent {...props} /> <RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</ComposerKeyboardScopeProvider> </ComposerKeyboardScopeProvider>
); );
} }
@@ -1238,6 +1248,7 @@ function ComposerContentImpl({
onPullRequestAdded: onGithubPrAutoAttach, onPullRequestAdded: onGithubPrAutoAttach,
}); });
const [cursorIndex, setCursorIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0);
const cursorPublication = useMemo(() => new AfterPaintPublication<number>(setCursorIndex), []);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
@@ -1322,12 +1333,20 @@ function ComposerContentImpl({
}); });
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = 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 // Clear send error when user edits the input
useEffect(() => { useEffect(() => {
setCursorIndex((current) => Math.min(current, userInput.length)); setCursorIndex((current) => Math.min(current, userInput.length));
}, [userInput.length]); }, [userInput.length]);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const { pickImages } = useImageAttachmentPicker(); const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker(); const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId); const agentIdRef = useRef(agentId);
@@ -1835,8 +1854,7 @@ function ComposerContentImpl({
// Handle keyboard navigation for command autocomplete. // Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback( const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => (event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
autocompleteOnKeyPressRef.current(event),
[], [],
); );
@@ -2084,9 +2102,16 @@ function ComposerContentImpl({
attachButtonRef.current = node; attachButtonRef.current = node;
}, []); }, []);
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => { const handleSelectionChange = useCallback(
setCursorIndex(selection.start); (selection: { start: number; end: number }) => {
}, []); if (isWeb) {
cursorPublication.stage(selection.start);
} else {
setCursorIndex(selection.start);
}
},
[cursorPublication],
);
const handleFocusChange = useCallback( const handleFocusChange = useCallback(
(focused: boolean) => { (focused: boolean) => {
@@ -2236,7 +2261,7 @@ function ComposerContentImpl({
anchorRef={messageInputContainerRef} anchorRef={messageInputContainerRef}
options={autocomplete.options} options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex} selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption} onSelect={handleAutocompleteSelect}
isLoading={autocomplete.isLoading} isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage} errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText} loadingText={autocomplete.loadingText}
@@ -2244,52 +2269,54 @@ function ComposerContentImpl({
/> />
{/* MessageInput handles everything: text, dictation, attachments, all buttons */} {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput <RenderProfile id="MessageInput">
ref={messageInputRef} <StableMessageInput
value={userInput} ref={messageInputRef}
onChangeText={setUserInput} value={userInput}
onSubmit={handleSubmit} onChangeText={setUserInput}
hasExternalContent={hasExternalContent} onSubmit={handleSubmit}
allowEmptySubmit={allowEmptySubmit} hasExternalContent={hasExternalContent}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} allowEmptySubmit={allowEmptySubmit}
submitButtonTestID={submitButtonTestID} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitIcon={submitIcon} submitButtonTestID={submitButtonTestID}
isSubmitDisabled={isSubmitDisabled} submitIcon={submitIcon}
isSubmitLoading={isSubmitLoadingVisible} isSubmitDisabled={isSubmitDisabled}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"} isSubmitLoading={isSubmitLoadingVisible}
attachments={selectedAttachments} preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
cwd={cwd} attachments={selectedAttachments}
attachmentMenuItems={attachmentMenuItems} cwd={cwd}
onAttachButtonRef={handleAttachButtonRef} attachmentMenuItems={attachmentMenuItems}
onAddImages={addImages} onAttachButtonRef={handleAttachButtonRef}
onPasteImages={handleNativePasteImages} onAddImages={addImages}
client={client} onPasteImages={handleNativePasteImages}
isReadyForDictation={isDictationReady} client={client}
placeholder={messagePlaceholder} isReadyForDictation={isDictationReady}
autoFocus={messageInputAutoFocus} placeholder={messagePlaceholder}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`} autoFocus={messageInputAutoFocus}
disabled={isSubmitLoading} autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
leftContent={leftContent} disabled={isSubmitLoading}
beforeVoiceContent={beforeVoiceContent} leftContent={leftContent}
rightContent={rightContent} beforeVoiceContent={beforeVoiceContent}
activeActionContent={activeActionContent} rightContent={rightContent}
voiceServerId={serverId} activeActionContent={activeActionContent}
voiceAgentId={agentId} voiceServerId={serverId}
isAgentRunning={isAgentRunning} voiceAgentId={agentId}
defaultSendBehavior={appSettings.sendBehavior} isAgentRunning={isAgentRunning}
onQueue={handleQueue} defaultSendBehavior={appSettings.sendBehavior}
onSubmitLoadingPress={submitLoadingPressHandler} onQueue={handleQueue}
onKeyPress={handleCommandKeyPress} onSubmitLoadingPress={submitLoadingPressHandler}
onSelectionChange={handleSelectionChange} onKeyPress={handleCommandKeyPress}
onFocusChange={handleFocusChange} onSelectionChange={handleSelectionChange}
onHeightChange={onComposerHeightChange} onFocusChange={handleFocusChange}
inputWrapperStyle={inputWrapperStyle} onHeightChange={onComposerHeightChange}
attachmentSlot={attachmentTray} inputWrapperStyle={inputWrapperStyle}
inputMode={inputMode} attachmentSlot={attachmentTray}
readOnly={readOnly} inputMode={inputMode}
textReplacementKey={textReplacementKey} readOnly={readOnly}
submitLabel={submitLabel} textReplacementKey={textReplacementKey}
/> submitLabel={submitLabel}
/>
</RenderProfile>
<Combobox <Combobox
options={githubSearchOptions} options={githubSearchOptions}
value="" value=""
+112 -55
View File
@@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image"; import type { NativePastedFile } from "@/composer/native-pasted-image";
@@ -87,6 +88,17 @@ export interface AttachmentMenuItem {
icon?: React.ReactElement | null; 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 { export interface MessageInputProps {
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
@@ -137,7 +149,7 @@ export interface MessageInputProps {
/** Optional handler used when submit button is in loading state. */ /** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void; onSubmitLoadingPress?: () => void;
/** Intercept key press events before default handling. Return true to prevent default. */ /** 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. */ /** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void; onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void; onFocusChange?: (focused: boolean) => void;
@@ -160,6 +172,7 @@ export interface MessageInputRef {
focus: () => void; focus: () => void;
blur: () => void; blur: () => void;
getText: () => string; getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void; replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/** /**
@@ -361,7 +374,8 @@ function SendButtonContent({
} }
interface DesktopKeyPressContext { interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean; submitOnEnter: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
@@ -382,6 +396,7 @@ function handleDesktopKeyPressImpl(
const handled = ctx.onKeyPressCallback({ const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key, key: event.nativeEvent.key,
preventDefault: () => event.preventDefault(), preventDefault: () => event.preventDefault(),
input: ctx.input,
}); });
if (handled) return; if (handled) return;
} }
@@ -775,11 +790,11 @@ function SendButtonTooltip({
type PrimaryActionKind = "send" | "active" | "none"; type PrimaryActionKind = "send" | "active" | "none";
function hasSendableComposerContent(input: { function hasSendableComposerContent(input: {
value: string; hasText: boolean;
attachments: readonly ComposerAttachment[]; attachments: readonly ComposerAttachment[];
hasExternalContent: boolean; hasExternalContent: boolean;
}): 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: { function resolvePrimaryActionKind(input: {
@@ -997,6 +1012,18 @@ function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHa
return null; 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 { interface SendButtonStateInput {
disabled: boolean; disabled: boolean;
isSubmitDisabled: boolean; isSubmitDisabled: boolean;
@@ -1055,7 +1082,7 @@ interface ResolvedMessageInputProps {
defaultSendBehavior: "interrupt" | "steer" | "queue"; defaultSendBehavior: "interrupt" | "steer" | "queue";
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => 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; onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined;
onFocusChange: ((focused: boolean) => void) | undefined; onFocusChange: ((focused: boolean) => void) | undefined;
onHeightChange: ((height: number) => void) | undefined; onHeightChange: ((height: number) => void) | undefined;
@@ -1181,20 +1208,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const focusInputKeys = useShortcutKeys("focus-message-input"); const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false); 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 rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null); const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null); const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false); const isInputFocusedRef = useRef(false);
const valueRef = useRef(value); const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacementKey); 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( const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => { (nextText: string, selection?: { start: number; end: number }) => {
valueRef.current = nextText; valueRef.current = nextText;
updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
textInputRef.current?.replaceText(nextText, selection); textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText); onChangeText(nextText);
}, },
[onChangeText], [onChangeText, updateLiveTextPresence],
); );
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -1205,6 +1247,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textInputRef.current?.blur(); textInputRef.current?.blur();
}, },
getText: () => textInputRef.current?.getText() ?? valueRef.current, getText: () => textInputRef.current?.getText() ?? valueRef.current,
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText, replaceText,
runKeyboardAction: (action) => runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, { runMessageInputKeyboardAction(action, {
@@ -1240,8 +1284,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (appliedTextReplacementKeyRef.current === textReplacementKey) return; if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey; appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value; valueRef.current = value;
updateLiveTextPresence(value);
textInputRef.current?.replaceText(value); textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]); }, [textReplacementKey, updateLiveTextPresence, value]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1443,30 +1488,33 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange?.(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT);
}, [onHeightChange]); }, [onHeightChange]);
const handleSendMessage = useCallback( const handleSendMessage = useCallback(() => {
() => const liveValue = textInputRef.current?.getText() ?? valueRef.current;
sendMessageImpl({ if (!preserveHeightOnSubmit) {
value: textInputRef.current?.getText() ?? valueRef.current, updateLiveTextPresence("");
attachments, }
hasExternalContent, sendMessageImpl({
allowEmptySubmit, value: liveValue,
cwd,
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
}),
[
allowEmptySubmit,
attachments, attachments,
cwd,
onSubmit,
isAgentRunning,
hasExternalContent, hasExternalContent,
minimizeInputHeight, allowEmptySubmit,
cwd,
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit, preserveHeightOnSubmit,
], });
); }, [
allowEmptySubmit,
attachments,
cwd,
onSubmit,
isAgentRunning,
hasExternalContent,
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
]);
const handleQueueMessage = useCallback( const handleQueueMessage = useCallback(
() => () =>
@@ -1558,6 +1606,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const start = event.nativeEvent.selection?.start ?? 0; const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start; const end = event.nativeEvent.selection?.end ?? start;
selectionRef.current = { start, end };
onSelectionChangeCallback?.({ start, end }); onSelectionChangeCallback?.({ start, end });
}, },
[onSelectionChangeCallback], [onSelectionChangeCallback],
@@ -1570,6 +1619,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (!shouldHandleWebKeyPress) return; if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, { handleDesktopKeyPressImpl(event, {
onKeyPressCallback, onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter, submitOnEnter: shouldSubmitOnEnter,
isAgentRunning, isAgentRunning,
onQueue, onQueue,
@@ -1583,7 +1637,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const primaryActionKind = resolvePrimaryActionKind({ const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({ hasSendableContent: hasSendableComposerContent({
value, hasText: hasLiveText,
attachments, attachments,
hasExternalContent, hasExternalContent,
}), }),
@@ -1635,9 +1689,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
valueRef.current = nextValue; valueRef.current = nextValue;
updateLiveTextPresence(nextValue);
onChangeText(nextValue); onChangeText(nextValue);
}, },
[onChangeText], [onChangeText, updateLiveTextPresence],
); );
const handleInputFocus = useCallback(() => { const handleInputFocus = useCallback(() => {
@@ -1762,31 +1817,33 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
> >
{attachmentSlot} {attachmentSlot}
{/* Text input */} {/* Text input */}
<ComposerTextSurface <RenderProfile id="ComposerTextSurface">
readOnly={readOnly} <ComposerTextSurface
value={value} readOnly={readOnly}
textInputRef={textInputRef} value={value}
textInputStyle={textInputStyle} textInputRef={textInputRef}
readOnlyTextStyle={readOnlyTextStyle} textInputStyle={textInputStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")} readOnlyTextStyle={readOnlyTextStyle}
accessibilityLabel={t(mode.accessibilityLabelKey)} placeholder={placeholder ?? t("composer.placeholders.fallback")}
onChangeText={handleInputChange} accessibilityLabel={t(mode.accessibilityLabelKey)}
onFocus={handleInputFocus} onChangeText={handleInputChange}
onBlur={handleInputBlur} onFocus={handleInputFocus}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onBlur={handleInputBlur}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
autoFocus={false} scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
onContentSizeChange={handleContentSizeChange} autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onContentSizeChange={handleContentSizeChange}
onSelectionChange={handleSelectionChange} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onPasteImages={onPasteImages} onSelectionChange={handleSelectionChange}
onPasteError={handlePasteError} onPasteImages={onPasteImages}
focusHintVisible={isWeb && !isInputFocused && !value} onPasteError={handlePasteError}
focusInputKeys={focusInputKeys} focusHintVisible={isWeb && !isInputFocused && !value}
focusHintLabel={t("composer.input.focusHint", { focusInputKeys={focusInputKeys}
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", focusHintLabel={t("composer.input.focusHint", {
})} shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
/> })}
/>
</RenderProfile>
{/* Button row */} {/* Button row */}
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
@@ -1,6 +1,6 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client"; 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"; import { ComposerTextInput } from "./text-input.web";
interface MountedInput { interface MountedInput {
@@ -73,12 +73,31 @@ afterEach(() => {
act(() => mounted.root.unmount()); act(() => mounted.root.unmount());
mounted.container.remove(); mounted.container.remove();
} }
vi.useRealTimers();
}); });
describe("ComposerTextInput web IME composition", () => { 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", () => { it("keeps the DOM-owned candidate during composition and reports the committed text", () => {
vi.useFakeTimers();
const recorder = createTextRecorder(); const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText); const mounted = mountInput(recorder.onChangeText);
@@ -100,14 +119,12 @@ describe("ComposerTextInput web IME composition", () => {
act(() => { act(() => {
dispatchComposition(mounted.textarea, "compositionend"); dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
}); });
expect(recorder.changes).toEqual(["你好"]); expect(recorder.changes).toEqual(["你好"]);
}); });
it("does not let a previous composition-end timer take ownership from a new composition", () => { it("keeps the latest candidate across consecutive compositions", () => {
vi.useFakeTimers();
const mounted = mountInput(ignoreTextChange); const mounted = mountInput(ignoreTextChange);
act(() => { act(() => {
@@ -116,7 +133,6 @@ describe("ComposerTextInput web IME composition", () => {
dispatchComposition(mounted.textarea, "compositionend"); dispatchComposition(mounted.textarea, "compositionend");
dispatchComposition(mounted.textarea, "compositionstart"); dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好"; mounted.textarea.value = "你好";
vi.runAllTimers();
mounted.root.render( mounted.root.render(
<ComposerTextInput <ComposerTextInput
text="你" text="你"
@@ -132,7 +148,6 @@ describe("ComposerTextInput web IME composition", () => {
}); });
it("does not report committed text twice when the input event already reported it", () => { it("does not report committed text twice when the input event already reported it", () => {
vi.useFakeTimers();
const changes: string[] = []; const changes: string[] = [];
const mounted = mountInput((text) => changes.push(text)); const mounted = mountInput((text) => changes.push(text));
@@ -140,7 +155,6 @@ describe("ComposerTextInput web IME composition", () => {
dispatchComposition(mounted.textarea, "compositionstart"); dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好"); typeFromIme(mounted.textarea, "你好");
dispatchComposition(mounted.textarea, "compositionend"); dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
}); });
expect(changes).toEqual(["你好"]); expect(changes).toEqual(["你好"]);
@@ -1,11 +1,4 @@
import React, { import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { TextInput } from "react-native"; import { TextInput } from "react-native";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
@@ -13,11 +6,8 @@ import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-inp
interface WebTextInputElement extends TextInput { interface WebTextInputElement extends TextInput {
value?: string; value?: string;
setSelectionRange?: (start: number, end: number) => void; setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void; addEventListener: (type: "compositionend", listener: EventListener) => void;
removeEventListener: ( removeEventListener: (type: "compositionend", listener: EventListener) => void;
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
} }
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
@@ -32,39 +22,22 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text); const textRef = useRef(text);
const onChangeTextRef = useRef(onChangeText); const onChangeTextRef = useRef(onChangeText);
const [isComposing, setIsComposing] = useState(false);
if (!isComposing) textRef.current = text;
onChangeTextRef.current = onChangeText; onChangeTextRef.current = onChangeText;
useEffect(() => { useEffect(() => {
const input = inputRef.current as WebTextInputElement | null; const input = inputRef.current as WebTextInputElement | null;
if (!input) return; if (!input) return;
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
const startComposition = () => {
if (compositionEndTimer !== null) {
clearTimeout(compositionEndTimer);
compositionEndTimer = null;
}
setIsComposing(true);
};
const endComposition = () => { const endComposition = () => {
const nextText = input.value ?? ""; const nextText = input.value ?? "";
if (nextText !== textRef.current) { if (nextText !== textRef.current) {
textRef.current = nextText; textRef.current = nextText;
onChangeTextRef.current(nextText); onChangeTextRef.current(nextText);
} }
compositionEndTimer = setTimeout(() => {
compositionEndTimer = null;
setIsComposing(false);
}, 0);
}; };
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition); input.addEventListener("compositionend", endComposition);
return () => { return () => {
if (compositionEndTimer !== null) clearTimeout(compositionEndTimer);
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition); input.removeEventListener("compositionend", endComposition);
}; };
}, []); }, []);
@@ -99,7 +72,7 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
<ThemedTextInput <ThemedTextInput
{...props} {...props}
ref={inputRef} ref={inputRef}
value={isComposing ? undefined : text} defaultValue={text}
onChangeText={handleChangeText} onChangeText={handleChangeText}
/> />
); );
@@ -38,6 +38,17 @@ interface UseAgentAutocompleteInput {
canExecuteClientSlashCommand?: boolean; canExecuteClientSlashCommand?: boolean;
} }
interface AgentAutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
input: AgentAutocompleteInputSnapshot;
}
interface AgentAutocompleteInputSnapshot {
text: string;
selection: { start: number; end: number };
}
type AgentAutocompleteOption = type AgentAutocompleteOption =
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand }) | (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
| (AutocompleteOption & { type: "provider_command" }) | (AutocompleteOption & { type: "provider_command" })
@@ -55,8 +66,38 @@ interface AgentAutocompleteResult {
errorMessage?: string; errorMessage?: string;
loadingText: string; loadingText: string;
emptyText: string; emptyText: string;
onSelectOption: (option: AutocompleteOption) => void; onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; 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 { interface DirectorySuggestionEntry {
@@ -426,8 +467,18 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
); );
const onSelectOption = useCallback( const onSelectOption = useCallback(
(option: AutocompleteOption) => { (option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
const selected = option as AgentAutocompleteOption; 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 ( if (
selected.type === "client_command" && selected.type === "client_command" &&
selected.command.execution === "immediate" && selected.command.execution === "immediate" &&
@@ -438,16 +489,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return; return;
} }
if (selected.type === "client_command" || selected.type === "provider_command") { if (selectedIsCommand) {
if (!activeSlashCommand) { if (!current.slashCommand) {
setUserInput(`/${selected.id} `); setUserInput(`/${selected.id} `);
onAutocompleteApplied?.(); onAutocompleteApplied?.();
return; return;
} }
const nextInput = applySlashCommandReplacement({ const nextInput = applySlashCommandReplacement({
text: userInput, text: current.text,
command: activeSlashCommand, command: current.slashCommand,
commandName: selected.id, commandName: selected.id,
}); });
setUserInput(nextInput); setUserInput(nextInput);
@@ -455,9 +506,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return; return;
} }
if (!current.fileMention) return;
const nextInput = applyFileMentionReplacement({ const nextInput = applyFileMentionReplacement({
text: userInput, text: current.text,
mention: selected.mention, mention: current.fileMention,
relativePath: selected.entryPath, relativePath: selected.entryPath,
}); });
setUserInput(nextInput); setUserInput(nextInput);
@@ -469,15 +521,23 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
onClientSlashCommand, onClientSlashCommand,
setUserInput, setUserInput,
userInput, userInput,
cursorIndex,
activeFileMention,
activeSlashCommand, activeSlashCommand,
], ],
); );
const selectOptionFromKeyPress = useCallback(
(option: AutocompleteOption, event?: AgentAutocompleteKeyPressEvent) =>
onSelectOption(option, event?.input),
[onSelectOption],
);
const { selectedIndex, onKeyPress } = useAutocomplete({ const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible, isVisible,
options, options,
query: mode === "command" ? commandFilterQuery : fileFilterQuery, query: mode === "command" ? commandFilterQuery : fileFilterQuery,
onSelectOption, onSelectOption: selectOptionFromKeyPress,
onEscape: onEscape:
mode === "command" && activeSlashCommand?.position === "start" mode === "command" && activeSlashCommand?.position === "start"
? () => setUserInput("") ? () => setUserInput("")
+18 -9
View File
@@ -5,23 +5,32 @@ import {
type AutocompleteOptionsPosition, type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils"; } from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> { interface AutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
}
interface UseAutocompleteInput<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
> {
isVisible: boolean; isVisible: boolean;
options: readonly TOption[]; options: readonly TOption[];
query: string; query: string;
onSelectOption: (option: TOption) => void; onSelectOption: (option: TOption, event?: TKeyPressEvent) => void;
onEscape?: () => void; onEscape?: () => void;
optionsPosition?: AutocompleteOptionsPosition; optionsPosition?: AutocompleteOptionsPosition;
} }
interface UseAutocompleteResult { interface UseAutocompleteResult<TKeyPressEvent extends AutocompleteKeyPressEvent> {
selectedIndex: number; selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; onKeyPress: (event: TKeyPressEvent) => boolean;
} }
export function useAutocomplete<TOption>( export function useAutocomplete<
input: UseAutocompleteInput<TOption>, TOption,
): UseAutocompleteResult { TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef(""); const previousQueryRef = useRef("");
@@ -56,7 +65,7 @@ export function useAutocomplete<TOption>(
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]); }, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
const onKeyPress = useCallback( const onKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => { (event: TKeyPressEvent) => {
if (!input.isVisible || input.options.length === 0) { if (!input.isVisible || input.options.length === 0) {
return false; return false;
} }
@@ -97,7 +106,7 @@ export function useAutocomplete<TOption>(
: fallbackIndex; : fallbackIndex;
const selectedOption = input.options[resolvedIndex]; const selectedOption = input.options[resolvedIndex];
if (selectedOption) { if (selectedOption) {
input.onSelectOption(selectedOption); input.onSelectOption(selectedOption, event);
} }
return true; return true;
} }
@@ -56,7 +56,6 @@ import {
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { import {
@@ -744,7 +743,7 @@ function normalizeBranchDetails(
interface SubmitDraftInput { interface SubmitDraftInput {
serverId: string; serverId: string;
draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string; draftId?: string;
initialSetup?: WorkspaceDraftTabSetup; initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string; workspaceId: string;
@@ -855,7 +854,7 @@ interface CreateChatAgentInput {
withInitialAgent: boolean; withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>; }) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string; serverId: string;
draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string; draftId?: string;
supportsForgeSearch: boolean; supportsForgeSearch: boolean;
labels: { labels: {
@@ -919,7 +918,7 @@ function buildComposerInitialValues(input: {
} }
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> { 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; const { text, attachments, cwd } = payload;
if (!composerState) { if (!composerState) {
throw new Error(input.labels.composerStateRequired); throw new Error(input.labels.composerStateRequired);
@@ -949,7 +948,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
}); });
submitWorkspaceDraft({ submitWorkspaceDraft({
serverId, serverId,
draftKey, clearDraft,
draftId: input.draftId, draftId: input.draftId,
initialSetup, initialSetup,
workspaceId: ensuredWorkspace.id, workspaceId: ensuredWorkspace.id,
@@ -1026,7 +1025,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
function submitWorkspaceDraft(input: SubmitDraftInput): void { function submitWorkspaceDraft(input: SubmitDraftInput): void {
const { const {
serverId, serverId,
draftKey, clearDraft,
draftId: draftIdInput, draftId: draftIdInput,
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
@@ -1078,12 +1077,12 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
...(submission.featureValues ? { featureValues: submission.featureValues } : {}), ...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true, allowEmptyText: true,
}); });
clearDraft("sent");
navigateToWorkspace({ navigateToWorkspace({
serverId, serverId,
workspaceId, workspaceId,
target: submission.target, target: submission.target,
}); });
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} }
function useNewWorkspaceHostSelector(input: { function useNewWorkspaceHostSelector(input: {
@@ -2071,7 +2070,7 @@ export function NewWorkspaceScreen({
forkDraftSetup, forkDraftSetup,
ensureWorkspace, ensureWorkspace,
serverId: selectedServerId, serverId: selectedServerId,
draftKey, clearDraft: chatDraft.clear,
draftId, draftId,
supportsForgeSearch, supportsForgeSearch,
labels: { labels: {
@@ -2089,7 +2088,7 @@ export function NewWorkspaceScreen({
[ [
composerState, composerState,
draftId, draftId,
draftKey, chatDraft.clear,
ensureWorkspace, ensureWorkspace,
forkDraftSetup, forkDraftSetup,
launchTarget, launchTarget,