Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user