mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3472fe51 | ||
|
|
e34aa277a2 |
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||
measurements so profiling overhead does not contaminate them.
|
||||
|
||||
For diff layout, two benchmarks share one corpus
|
||||
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
|
||||
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
|
||||
shared document model with the web and native measurers replaced by counters, so it reports how
|
||||
many strings each backend would have to shape, which is the cost that survives the move to canvas
|
||||
or Skia:
|
||||
|
||||
```bash
|
||||
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
```
|
||||
|
||||
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
|
||||
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
|
||||
React commit time for cold and warm toggles, then scroll frame times in both modes:
|
||||
|
||||
```bash
|
||||
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
|
||||
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
|
||||
```
|
||||
|
||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||
That report includes input and composer height changes plus React work grouped into composer,
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
|
||||
Generated
+9
-9
@@ -37625,8 +37625,8 @@
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
@@ -38535,12 +38535,12 @@
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.291",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
|
||||
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
|
||||
"version": "0.20.0-beta.212",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.212.tgz",
|
||||
"integrity": "sha512-RzaTYeukTXTJth+Sl7FzCmb8AqiyDrNWNnqG+/300qJbfHYfCMmBlBVZ0yCTSZIWMdgFbgkhmnmBN7Yzs6xoBA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.292"
|
||||
"@xterm/xterm": "^6.1.0-beta.213"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/headless": {
|
||||
@@ -38554,9 +38554,9 @@
|
||||
]
|
||||
},
|
||||
"packages/app/node_modules/@xterm/xterm": {
|
||||
"version": "6.1.0-beta.292",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
|
||||
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
|
||||
"version": "6.1.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.213.tgz",
|
||||
"integrity": "sha512-bq5nJkgoZDSg6Ma4zgiQ7jlooGNo7U+HDa6vnhx43yXsVEK2laIF7R08bMYAuU0B+C9xX0gwSrcsW18GxjUoJQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
expectToolCallGroupPositionPreserved,
|
||||
rememberToolCallGroupPosition,
|
||||
seedItemPagedToolGroupTimeline,
|
||||
useChatDisplay,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts paged by items can end a page inside a tool run. The older page extends the group
|
||||
// the reader is on, which changes the row hosting it; the reader must stay with that group
|
||||
// rather than land at history start and drain every older page.
|
||||
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseToolCalls");
|
||||
const agent = await seedItemPagedToolGroupTimeline();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the user navigates to history start with the keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -297,187 +296,8 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
|
||||
test.setTimeout(300_000);
|
||||
const corpus = await createPullRequestCorpusRepository();
|
||||
const client = await connectSeedClient();
|
||||
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: corpus.repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create wrap performance workspace");
|
||||
}
|
||||
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await instrumentDiffLatency(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const openStartedAt = performance.now();
|
||||
await openChangesPanel(page, 90_000);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
const open = {
|
||||
readyMs: Math.round(performance.now() - openStartedAt),
|
||||
...(await readDiffDocumentRenders(page)),
|
||||
};
|
||||
|
||||
const toggles: WrapToggleMeasurement[] = [];
|
||||
for (const name of [
|
||||
"wrap on (cold)",
|
||||
"wrap off (cold)",
|
||||
"wrap on (warm)",
|
||||
"wrap off (warm)",
|
||||
]) {
|
||||
toggles.push(await measureWrapToggle(page, name));
|
||||
}
|
||||
const unwrappedScroll = await continuouslyScrollCanvas(page);
|
||||
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
|
||||
const wrappedScroll = await continuouslyScrollCanvas(page);
|
||||
|
||||
const report = {
|
||||
scenario: "wrap-toggle",
|
||||
sourceFiles: corpus.fileCount,
|
||||
sourceLines: corpus.lineCount,
|
||||
open,
|
||||
toggles,
|
||||
scroll: {
|
||||
unwrapped: summarizeScroll(unwrappedScroll),
|
||||
wrapped: summarizeScroll(wrappedScroll),
|
||||
},
|
||||
};
|
||||
await attachPerformanceReport(testInfo, report);
|
||||
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
|
||||
|
||||
const [wrapOn, wrapOff] = toggles;
|
||||
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
|
||||
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await corpus.repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface WrapToggleMeasurement {
|
||||
name: string;
|
||||
/** Pointer down on the toolbar button to two painted frames after the document resized. */
|
||||
latencyMs: number;
|
||||
scrollHeightBefore: number;
|
||||
scrollHeightAfter: number;
|
||||
longestTaskMs: number;
|
||||
longTaskTotalMs: number;
|
||||
diffDocumentCommits: number;
|
||||
diffDocumentRenderMs: number;
|
||||
}
|
||||
|
||||
interface LongTask {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DiffLatencyScope {
|
||||
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
|
||||
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
|
||||
__PASEO_RESET_RENDER_PROFILE__?: () => void;
|
||||
__PASEO_LONG_TASKS__?: LongTask[];
|
||||
__PASEO_WRAP_TOGGLE_AT__?: number;
|
||||
}
|
||||
|
||||
async function createPullRequestCorpusRepository() {
|
||||
const files = pullRequestCorpus();
|
||||
const repo = await createTempGitRepo("diff-wrap-perf-", {
|
||||
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
|
||||
});
|
||||
for (const file of files) {
|
||||
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
|
||||
}
|
||||
return {
|
||||
repo,
|
||||
fileCount: files.length,
|
||||
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function instrumentDiffLatency(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
|
||||
}
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function readDiffDocumentRenders(
|
||||
page: Page,
|
||||
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
|
||||
return page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
|
||||
(sample) => sample.id === "DiffDocument",
|
||||
);
|
||||
return {
|
||||
diffDocumentCommits: samples.length,
|
||||
diffDocumentRenderMs: Math.round(
|
||||
samples.reduce((total, sample) => total + sample.actualDuration, 0),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
|
||||
await page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await page.getByTestId("changes-toggle-wrap-lines").click();
|
||||
const measured = await scroll.evaluate(async (element, before) => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const deadline = performance.now() + 240_000;
|
||||
while (element.scrollHeight === before && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const doneAt = performance.now();
|
||||
// Long task entries are delivered after the task ends; give the observer a turn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
|
||||
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
|
||||
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
|
||||
);
|
||||
return {
|
||||
latencyMs: Math.round(doneAt - startedAt),
|
||||
scrollHeightAfter: element.scrollHeight,
|
||||
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
|
||||
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
|
||||
};
|
||||
}, scrollHeightBefore);
|
||||
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
|
||||
}
|
||||
|
||||
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
|
||||
const { frameSignatures: _frameSignatures, ...summary } = report;
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureAdditionalPerformanceScenario(
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
|
||||
@@ -5,22 +5,86 @@ import {
|
||||
getTerminalBufferText,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { metroTest as test, expect, type Page } from "../support/fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectFoldedTurnAtReadingPosition,
|
||||
expectOldestLoadedTurnPartial,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
expectTimelinePromptVisible,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
interface PublishedDaemonAgent {
|
||||
serverId: string;
|
||||
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
agent: MockAgentWorkspace;
|
||||
/** Register the host in the browser and apply app settings before the first load. */
|
||||
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startPublishedDaemonAgent(options: {
|
||||
publishedVersion: string;
|
||||
serverIdPrefix: string;
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
}): Promise<PublishedDaemonAgent> {
|
||||
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
publishedVersion: options.publishedVersion,
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
return {
|
||||
serverId,
|
||||
daemon,
|
||||
agent,
|
||||
async seedBrowser(page, appSettings) {
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences, settings }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
|
||||
);
|
||||
},
|
||||
async close() {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
|
||||
const observationSockets = new Set<unknown>();
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
@@ -34,43 +98,18 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const workspace = await seedWorkspace({
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.2.5",
|
||||
serverIdPrefix: "srv_old_pagination_",
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
port: daemon.port,
|
||||
});
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Published daemon pagination regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
const { agent, serverId } = published;
|
||||
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await published.seedBrowser(page);
|
||||
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
@@ -87,7 +126,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||
expect(terminal.error).toBeNull();
|
||||
const terminalId = terminal.terminal!.id;
|
||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
|
||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||
await focusTerminalSurface(page);
|
||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||
@@ -97,7 +136,46 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
|
||||
// starts mid-turn and only folds once the next page completes it; the reader must stay
|
||||
// with that turn instead of landing at history start and draining every older page.
|
||||
test("keeps the reader on the turn that folds when an older item page completes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.13.0-beta.2",
|
||||
serverIdPrefix: "srv_old_fold_",
|
||||
repoPrefix: "timeline-old-daemon-fold-",
|
||||
title: "Published daemon folded turn pagination",
|
||||
});
|
||||
const { agent, serverId } = published;
|
||||
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
|
||||
|
||||
try {
|
||||
for (const prompt of prompts) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
|
||||
const history = await holdOlderHistoryPages(page, agent, {
|
||||
daemonPort: published.daemon.port,
|
||||
});
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, prompts.at(-1)!);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
.getByText("Sent by Reviewer", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||
await attribution.click();
|
||||
@@ -202,6 +203,22 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
}
|
||||
});
|
||||
|
||||
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||
const edges = await attribution.evaluate((button) => {
|
||||
const bubble = button.nextElementSibling;
|
||||
const label = button.querySelector('[dir="auto"]');
|
||||
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
|
||||
return {
|
||||
labelRight: label.getBoundingClientRect().right,
|
||||
bubbleRight: bubble.getBoundingClientRect().right,
|
||||
};
|
||||
});
|
||||
expect(
|
||||
Math.abs(edges.labelRight - edges.bubbleRight),
|
||||
"sender and bubble share the right edge",
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function seedWholeTurnHistory() {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "whole-turn-history-",
|
||||
@@ -301,6 +318,7 @@ for (const autoExpandReasoning of [false, true]) {
|
||||
await expectComposerVisible(page);
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary).not.toContainText(/thought/i);
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
await summary.click();
|
||||
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
|
||||
@@ -339,9 +357,10 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
const group = page
|
||||
.getByTestId("tool-call-group")
|
||||
.filter({ hasText: /thought/, visible: true })
|
||||
.filter({ hasText: /read 1 file/i, visible: true })
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesOptions {
|
||||
daemonPort?: number;
|
||||
/**
|
||||
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
|
||||
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
|
||||
*/
|
||||
itemPagedHost?: boolean;
|
||||
}
|
||||
|
||||
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
|
||||
const envelope = parseWebSocketJson(message) as {
|
||||
message: { payload: { features?: Record<string, unknown> } };
|
||||
};
|
||||
delete envelope.message.payload.features?.wholeTurnTimeline;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
daemonPort?: number,
|
||||
options: OlderTimelinePagesOptions = {},
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
const { daemonPort } = options;
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedEntryCount = 0;
|
||||
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
server.onMessage((received) => {
|
||||
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
type OlderTimelinePagesOptions,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
/** Commands in one run, more than an item page holds. */
|
||||
totalCommands: number;
|
||||
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
|
||||
loadedCommands: number;
|
||||
}
|
||||
|
||||
interface ToolCallGroupPositionSnapshot {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const ITEM_PAGE_SIZE = 40;
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of commands longer than an item page, followed by enough short turns that the
|
||||
* initial tail ends inside the run. Read with item pages, the tail loads the newest
|
||||
* commands as one group; the older page carries the rest of that run and the turns before
|
||||
* it, with a further page behind it.
|
||||
*/
|
||||
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-item-page-tool-group-",
|
||||
title: "Item page tool group regression",
|
||||
});
|
||||
const olderTurns = 10;
|
||||
const newerTurns = 12;
|
||||
const totalCommands = 50;
|
||||
// A short turn is its prompt and one reply; the run's turn ends with one reply.
|
||||
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
|
||||
const shortTurnPrompt = (label: string, index: number) =>
|
||||
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
|
||||
const sendTurn = async (prompt: string) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
};
|
||||
|
||||
for (let index = 0; index < olderTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("older", index));
|
||||
}
|
||||
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
|
||||
for (let index = 0; index < newerTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("newer", index));
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
|
||||
totalCommands,
|
||||
loadedCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export async function useChatDisplay(
|
||||
page: Page,
|
||||
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
function toolCallGroup(page: Page, commandCount: number): Locator {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
return timeline
|
||||
.getByTestId("tool-call-group")
|
||||
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
|
||||
}
|
||||
|
||||
export async function rememberToolCallGroupPosition(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
): Promise<ToolCallGroupPositionSnapshot> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
const box = await group.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
|
||||
}
|
||||
return { top: box.y };
|
||||
}
|
||||
|
||||
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
|
||||
export async function expectToolCallGroupPositionPreserved(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
before: ToolCallGroupPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const box = await group.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
},
|
||||
{ message: `the group of ${commandCount} commands keeps its viewport position` },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function scrollThroughOlderHistoryPages(
|
||||
page: Page,
|
||||
pageCount: number,
|
||||
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
daemonPort?: number,
|
||||
options?: OlderTimelinePagesOptions,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||
page,
|
||||
agent.agentId,
|
||||
daemonPort,
|
||||
options,
|
||||
);
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
// The loader stays until the prepend settles and the viewport decides whether to
|
||||
// keep loading. Once it hides, no further page loads without new user intent.
|
||||
await expect
|
||||
.poll(
|
||||
async () => ({
|
||||
requestedPages: gate.getRequestCount(),
|
||||
loading: await loader.isVisible(),
|
||||
}),
|
||||
{ message: `older history settles after ${count} requested page(s)` },
|
||||
)
|
||||
.toEqual({ requestedPages: count, loading: false });
|
||||
},
|
||||
expectNoRepeatedEntries() {
|
||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
|
||||
* mid-turn: its prompt is above loaded history. Returns that prompt.
|
||||
*/
|
||||
export async function expectOldestLoadedTurnPartial(
|
||||
page: Page,
|
||||
prompts: readonly string[],
|
||||
): Promise<string> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const oldestLoadedPrompt = await timeline
|
||||
.getByTestId("user-message")
|
||||
.first()
|
||||
.evaluate((element) => element.textContent ?? "");
|
||||
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
|
||||
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
|
||||
const oldestRow = timeline.locator("[data-history-row-id]").first();
|
||||
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
|
||||
0,
|
||||
);
|
||||
const partialPrompt = prompts[loadedIndex - 1]!;
|
||||
await expectTimelinePromptNotMounted(page, partialPrompt);
|
||||
return partialPrompt;
|
||||
}
|
||||
|
||||
/** The reader stays with the turn they were reading once it folds behind its summary. */
|
||||
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [timelineBox, promptBox] = await Promise.all([
|
||||
timeline.boundingBox(),
|
||||
promptRow.boundingBox(),
|
||||
]);
|
||||
if (!timelineBox || !promptBox) return false;
|
||||
const offset = promptBox.y - timelineBox.y;
|
||||
return offset >= 0 && offset <= timelineBox.height / 2;
|
||||
},
|
||||
{ message: "folded turn sits in the upper half of the viewport" },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -609,8 +610,54 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
"preserves the reader when a prepend extends a group in %s",
|
||||
(display) => {
|
||||
const present = createStreamPresentation();
|
||||
const anchor = createReadingAnchor();
|
||||
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
display,
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
};
|
||||
const partial = present({ ...input, tail: calls.slice(1) });
|
||||
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
|
||||
const completed = present({ ...input, tail: calls });
|
||||
const groupId = completed.tail[0]!.id;
|
||||
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
[{ id: groupId, top: 448, height: 200 }],
|
||||
false,
|
||||
completed.readingRowReplacements,
|
||||
),
|
||||
).toBe(400);
|
||||
expect(anchor.getRowId()).toBe(groupId);
|
||||
const timestamp = new Date();
|
||||
const folded = present({
|
||||
...input,
|
||||
display: "collapseTurns",
|
||||
tail: [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
...calls,
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
|
||||
const call = toolCall("received", "agent_message");
|
||||
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
@@ -26,6 +27,22 @@ interface PresentationInput {
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
readingRowReplacements: ReadingRowReplacements;
|
||||
}
|
||||
|
||||
function groupedRowHosts(
|
||||
rows: readonly StreamItem[],
|
||||
groups: ToolCallDetailProjection["groupsByHostId"],
|
||||
): ReadonlyMap<string, string> {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const group = groups.get(row.id);
|
||||
if (!group) continue;
|
||||
for (const call of group.run.calls) {
|
||||
if (call.id !== row.id) hosts.set(call.id, row.id);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
@@ -61,6 +78,7 @@ export function createStreamPresentation() {
|
||||
let preparedAutoExpandReasoning: boolean | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
@@ -89,7 +107,7 @@ export function createStreamPresentation() {
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
): Omit<StreamPresentation, "readingRowReplacements"> {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
@@ -157,19 +175,42 @@ export function createStreamPresentation() {
|
||||
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||
) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||
historyGroupHosts = preparedHistory
|
||||
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
|
||||
: new Map();
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
const projection = projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
const result = collapse(projection, input);
|
||||
// Only the trailing retained group can absorb head rows. Reuse the retained
|
||||
// index across streaming updates rather than walking the full history again.
|
||||
const last = projection.tail.at(-1);
|
||||
const liveGroupHosts = groupedRowHosts(
|
||||
last ? [last, ...projection.head] : projection.head,
|
||||
projection.groupsByHostId,
|
||||
);
|
||||
const retainedGroupHosts = historyGroupHosts;
|
||||
return {
|
||||
...result,
|
||||
readingRowReplacements: {
|
||||
get(id) {
|
||||
if (result.turns.nestedItemIds.has(id)) return undefined;
|
||||
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
|
||||
return (
|
||||
result.turns.hostIdByHiddenRowId.get(id) ??
|
||||
result.turns.hostIdByHiddenRowId.get(groupHost) ??
|
||||
(groupHost === id ? undefined : groupHost)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
it("pins the folded turn host when a prepend replaces the row being read", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
expect(
|
||||
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
expect(anchor.getRowId()).toBe("prompt");
|
||||
expect(
|
||||
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
|
||||
@@ -6,6 +6,10 @@ interface RowGeometry {
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
get(id: string): string | undefined;
|
||||
}
|
||||
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
const getRowId = (replacements = NO_REPLACEMENTS) =>
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
rows: readonly RowGeometry[],
|
||||
userScrolled = false,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
|
||||
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
? readingAnchor.project(
|
||||
container.scrollTop,
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
const correctedTop = readingAnchor.reconcile(
|
||||
container.scrollTop,
|
||||
rows,
|
||||
false,
|
||||
props.readingRowReplacements,
|
||||
);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
import { continuesResponse } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
|
||||
segments: StreamRenderSegments;
|
||||
historyRowRevision?: StreamHistoryRowRevision;
|
||||
liveHeadRowRevision?: unknown;
|
||||
/** Presentation rows replaced by another row after history is folded. */
|
||||
readingRowReplacements?: ReadingRowReplacements;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
|
||||
@@ -48,6 +48,14 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
|
||||
});
|
||||
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
|
||||
const create = shell("create");
|
||||
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface TurnCollapse {
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -80,6 +82,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
@@ -114,6 +117,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
if (staysVisibleWhenCollapsed(item)) continue;
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
@@ -139,6 +143,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -149,5 +154,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MessageSender({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderProviderIcon}
|
||||
style={styles.sender}
|
||||
disabled={!host}
|
||||
onPress={openSender}
|
||||
>
|
||||
@@ -84,6 +85,7 @@ export function MessageSender({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||
body: { flexShrink: 0 },
|
||||
sender: { paddingRight: 0, borderRightWidth: 0 },
|
||||
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||
}));
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* The diff shapes the layout benchmarks measure, shared by the node bench
|
||||
* (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`)
|
||||
* so both report against the same text.
|
||||
*/
|
||||
export interface BenchmarkCorpusFile {
|
||||
path: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function repeatToLength(unit: string, length: number): string {
|
||||
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
|
||||
}
|
||||
|
||||
/** A real source file: short lines, a few with ligature punctuation. */
|
||||
export function realCodeCorpusFile(): BenchmarkCorpusFile {
|
||||
const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8");
|
||||
return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") };
|
||||
}
|
||||
|
||||
/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */
|
||||
export function changelogCorpusFile(): BenchmarkCorpusFile {
|
||||
const sentence =
|
||||
"- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. ";
|
||||
return {
|
||||
path: "CHANGELOG.md",
|
||||
lines: Array.from({ length: 42 }, (_, index) =>
|
||||
repeatToLength(sentence, 200 + ((index * 97) % 398)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */
|
||||
export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "src/generated.ts",
|
||||
lines: Array.from({ length: lineCount }, (_, index) => {
|
||||
const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`;
|
||||
return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialized data: no ligatures, every line wraps several times. */
|
||||
export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "fixtures/data.json",
|
||||
lines: Array.from({ length: lineCount }, (_, index) =>
|
||||
repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A pull-request-sized working diff: one of each shape above. */
|
||||
export function pullRequestCorpus(): BenchmarkCorpusFile[] {
|
||||
return [
|
||||
realCodeCorpusFile(),
|
||||
changelogCorpusFile(),
|
||||
generatedCodeCorpusFile(1200),
|
||||
jsonCorpusFile(300),
|
||||
];
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { bench, describe } from "vitest";
|
||||
import {
|
||||
changelogCorpusFile,
|
||||
generatedCodeCorpusFile,
|
||||
jsonCorpusFile,
|
||||
pullRequestCorpus,
|
||||
realCodeCorpusFile,
|
||||
type BenchmarkCorpusFile,
|
||||
} from "./benchmark-corpus";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import {
|
||||
createAdditiveAdvances,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createChunkedWidthMeasurer,
|
||||
createMeasuredAdvances,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
// The pure layout work the diff surface does when a diff opens or line wrap is
|
||||
// toggled, without a browser or Skia. Counts are the backend-independent cost
|
||||
// owners (how many strings a shaper has to see), timings are the JS overhead
|
||||
// around them:
|
||||
// npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1400, height: 900 };
|
||||
const PHONE_VIEWPORT = { width: 390, height: 800 };
|
||||
const CELL_WIDTH = 7.8;
|
||||
|
||||
interface MeasurementStats {
|
||||
/** Whole strings handed to the backend. */
|
||||
calls: number;
|
||||
/** Characters inside those strings; canvas and Skia cost scales with this. */
|
||||
characters: number;
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
measurer: TextMeasurer;
|
||||
stats: MeasurementStats;
|
||||
}
|
||||
|
||||
function monospaceWidth(text: string): number {
|
||||
let width = 0;
|
||||
for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1;
|
||||
return width * CELL_WIDTH;
|
||||
}
|
||||
|
||||
function countingMeasure(stats: MeasurementStats): (text: string) => number {
|
||||
return (text) => {
|
||||
stats.calls += 1;
|
||||
stats.characters += text.length;
|
||||
return monospaceWidth(text);
|
||||
};
|
||||
}
|
||||
|
||||
/** Previous web backend, retained to isolate the cost of width-only probes. */
|
||||
function webBackendWithoutWidthProbe(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter.
|
||||
* Width-only probes shape one string per chunk, like the native measurer; the
|
||||
* widths are the same sums, in the same order, as the last cumulative advance.
|
||||
*/
|
||||
function webBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */
|
||||
function nativeBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const widths = new Map<string, number>();
|
||||
const additive = (graphemes: readonly string[]) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
stats.calls += 1;
|
||||
stats.characters += grapheme.length;
|
||||
width = monospaceWidth(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
return (advance += width);
|
||||
});
|
||||
};
|
||||
const shape = countingMeasure(stats);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure(text) {
|
||||
return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0);
|
||||
},
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping,
|
||||
measureAdditive: additive,
|
||||
measureShaped(graphemes) {
|
||||
shape(graphemes.join(""));
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme)));
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile {
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: file.lines.length,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: file.lines.length,
|
||||
lines: file.lines.map((content) => ({
|
||||
type: "add" as const,
|
||||
content,
|
||||
tokens: [{ text: content, style: null }],
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{ name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] },
|
||||
{ name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] },
|
||||
{ name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] },
|
||||
{ name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] },
|
||||
{ name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) },
|
||||
];
|
||||
|
||||
function input(
|
||||
scenario: Scenario,
|
||||
options: {
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
measureText: TextMeasurer;
|
||||
},
|
||||
): BuildDiffDocumentModelInput {
|
||||
return {
|
||||
files: scenario.files,
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: options.wrapLines,
|
||||
viewportWidth: options.viewport.width,
|
||||
typography: { family: "monospace", size: 13, lineHeight: 20 },
|
||||
measureText: options.measureText,
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
// What the surfaces pass at scrollTop 0: five viewports tall.
|
||||
materializationWindow: { top: 0, height: options.viewport.height * 5 },
|
||||
};
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
name: string;
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
backend: () => Backend;
|
||||
}
|
||||
|
||||
const VARIANTS: Variant[] = [
|
||||
{
|
||||
name: "unwrapped desktop | web",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web without width probe",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackendWithoutWidthProbe,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | web",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "unwrapped desktop | native",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | native",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | native",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
];
|
||||
|
||||
function countModel(scenario: Scenario, variant: Variant) {
|
||||
const { measurer, stats } = variant.backend();
|
||||
const started = performance.now();
|
||||
const model = buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: measurer,
|
||||
}),
|
||||
);
|
||||
const elapsedMs = performance.now() - started;
|
||||
let materializedCells = 0;
|
||||
let fragments = 0;
|
||||
let graphemes = 0;
|
||||
for (const row of model.rows) {
|
||||
if (row.kind !== "line") continue;
|
||||
for (const cell of row.cells) {
|
||||
if (!cell || cell.fragments.length === 0) continue;
|
||||
materializedCells += 1;
|
||||
fragments += cell.fragments.length;
|
||||
for (const fragment of cell.fragments) graphemes += fragment.graphemes.length;
|
||||
}
|
||||
}
|
||||
const sourceLines = scenario.files.reduce(
|
||||
(count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
sourceLines,
|
||||
materializedCells,
|
||||
fragments,
|
||||
graphemeObjects: graphemes,
|
||||
measureCalls: stats.calls,
|
||||
measuredCharacters: stats.characters,
|
||||
coldMs: Number(elapsedMs.toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
const report: Record<string, ReturnType<typeof countModel>> = {};
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const variant of VARIANTS) {
|
||||
report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant);
|
||||
}
|
||||
}
|
||||
console.table(report);
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
describe(scenario.name, () => {
|
||||
for (const variant of VARIANTS) {
|
||||
if (variant.backend === nativeBackend) continue;
|
||||
bench(variant.name, () => {
|
||||
buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: variant.backend().measurer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveRelayoutScrollTop,
|
||||
shouldApplyRelayoutScroll,
|
||||
} from "./model";
|
||||
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
||||
|
||||
@@ -639,119 +639,17 @@ describe("diff document model", () => {
|
||||
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
||||
});
|
||||
|
||||
it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
|
||||
const changed = fileWithChangedLine("x".repeat(100));
|
||||
const files = [changed, file("later.ts")];
|
||||
// Deliberately inaccurate M width makes the estimate differ from exact layout.
|
||||
const measureText = {
|
||||
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
|
||||
};
|
||||
const options = input({ files, measureText });
|
||||
const first = buildDiffDocumentModel({
|
||||
...options,
|
||||
materializationWindow: { top: 30, height: 1 },
|
||||
});
|
||||
const eager = buildDiffDocumentModel(options);
|
||||
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
|
||||
expect(first.height).not.toBe(eager.height);
|
||||
expect(exact.rows).toEqual(eager.rows);
|
||||
expect(exact.height).toBe(eager.height);
|
||||
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
|
||||
const scrollTop = first.files[1]!.bodyTop + 3;
|
||||
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
|
||||
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
|
||||
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
|
||||
captureScrollAnchor(first, scrollTop),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 36;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
|
||||
const resizedOptions = { ...options, viewportWidth: 180 };
|
||||
const estimated = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: {
|
||||
top: Math.floor(scrollTop / 800) * 800 - 800,
|
||||
height: 2000,
|
||||
},
|
||||
});
|
||||
const estimatedRow = estimated.rows[100]!;
|
||||
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
|
||||
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
|
||||
const recaptured = captureScrollAnchor(estimated, restored)!;
|
||||
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
|
||||
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
reuseFrom: [estimated],
|
||||
});
|
||||
const eager = buildDiffDocumentModel(resizedOptions);
|
||||
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
|
||||
expect(expected).toBe(28_938);
|
||||
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
|
||||
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
|
||||
});
|
||||
|
||||
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 38;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
const resizedOptions = {
|
||||
...options,
|
||||
viewportWidth: 180,
|
||||
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
|
||||
};
|
||||
const resized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: scrollTop - 800, height: 2000 },
|
||||
materializationAnchor: anchor,
|
||||
});
|
||||
const row = resized.rows[100]!;
|
||||
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
|
||||
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
materializationAnchor: captureScrollAnchor(resized, restored),
|
||||
reuseFrom: [resized],
|
||||
});
|
||||
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
|
||||
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
|
||||
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
|
||||
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
|
||||
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
|
||||
});
|
||||
|
||||
it("materializes wrapped text only inside the window", () => {
|
||||
it("keeps wrapped geometry fully measured", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [file("a.ts"), file("b.ts")],
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
|
||||
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
||||
expect(measuredFragments(model, "b.ts")).toBe(0);
|
||||
expect(
|
||||
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
|
||||
).toHaveLength(1);
|
||||
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -900,7 +798,3 @@ function reviewActionsWithEditor(
|
||||
onDeleteComment() {},
|
||||
};
|
||||
}
|
||||
|
||||
function hasFragments(cell: DiffCell | null): boolean {
|
||||
return cell !== null && cell.fragments.length > 0;
|
||||
}
|
||||
|
||||
@@ -154,38 +154,27 @@ function appendReusableFileRows(candidate: {
|
||||
}): FileRowsResult {
|
||||
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
||||
const retainsRowGeometry =
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
let nextRow =
|
||||
retainsRowGeometry && reusedRow.top === top
|
||||
? reusedRow
|
||||
: repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
const cells = materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
);
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells,
|
||||
height:
|
||||
Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map(
|
||||
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
|
||||
),
|
||||
) + nextRow.reviewHeight,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
@@ -251,7 +240,6 @@ function appendNewFileRows(candidate: {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
candidate.gutterWidth,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
@@ -264,8 +252,8 @@ function appendNewFileRows(candidate: {
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
@@ -287,7 +275,7 @@ function appendNewFileRows(candidate: {
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -424,14 +412,13 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean):
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
|
||||
function geometryCell(source: CellSource): DiffCell {
|
||||
return {
|
||||
type: source.type,
|
||||
content: source.content,
|
||||
lineNumber: source.lineNumber,
|
||||
tokens: [],
|
||||
fragments: [],
|
||||
estimatedWrapColumns,
|
||||
reviewTarget: source.reviewTarget,
|
||||
sourceIdentity: source.sourceIdentity,
|
||||
};
|
||||
@@ -440,87 +427,20 @@ function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCel
|
||||
function geometryLines(
|
||||
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
gutterWidth: number,
|
||||
): GeometryLine[] {
|
||||
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
|
||||
return sources.map((sourceCells) => {
|
||||
const columns = input.wrapLines
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
|
||||
characterWidth,
|
||||
),
|
||||
)
|
||||
: Infinity;
|
||||
const cells = sourceCells.map((source) =>
|
||||
source ? geometryCell(source, columns) : null,
|
||||
source ? geometryCell(source) : null,
|
||||
) as DiffLineRow["cells"];
|
||||
const reviewHeight = reviewHeightForCells(cells, input);
|
||||
return {
|
||||
cells,
|
||||
reviewHeight,
|
||||
// Unseen wrapped rows use a monospace estimate; materialization replaces
|
||||
// it with the exact fragment count without changing source identity.
|
||||
height:
|
||||
input.typography.lineHeight *
|
||||
Math.max(
|
||||
1,
|
||||
...cells.map((cell) => {
|
||||
return cell && input.wrapLines
|
||||
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
|
||||
: 1;
|
||||
}),
|
||||
) +
|
||||
reviewHeight,
|
||||
height: input.typography.lineHeight + reviewHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Use the same tab expansion and column count for estimated heights and anchors.
|
||||
function estimatedDisplayOffset(text: string): number {
|
||||
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
|
||||
}
|
||||
|
||||
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
|
||||
return Math.floor(
|
||||
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
|
||||
(cell.estimatedWrapColumns ?? Infinity),
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
|
||||
const columns = cell.estimatedWrapColumns ?? Infinity;
|
||||
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
|
||||
const index = Math.min(visualIndex, lastIndex);
|
||||
const displayOffset = index === 0 ? 0 : index * columns;
|
||||
let start = Math.min(displayOffset, cell.content.length);
|
||||
if (cell.content.includes("\t")) {
|
||||
let offset = 0;
|
||||
start = 0;
|
||||
while (start < cell.content.length && offset < displayOffset) {
|
||||
offset += cell.content[start] === "\t" ? 4 : 1;
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
return { start, top: index * lineHeight };
|
||||
}
|
||||
|
||||
// A resize can move the anchor outside the old window. Measure that row in the
|
||||
// same pass: estimated columns cannot identify exact shaped-text fragments.
|
||||
function containsMaterializationAnchor(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
path: string,
|
||||
cells: DiffLineRow["cells"],
|
||||
): boolean {
|
||||
const anchor = input.materializationAnchor;
|
||||
return (
|
||||
anchor?.kind === "text" &&
|
||||
anchor.path === path &&
|
||||
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
|
||||
);
|
||||
}
|
||||
|
||||
function intersectsMaterializationWindow(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
fileTop: number,
|
||||
@@ -875,27 +795,17 @@ export function captureScrollAnchor(
|
||||
}
|
||||
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
||||
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
||||
let cellIndex = row.cells.findIndex(
|
||||
(cell) =>
|
||||
cell &&
|
||||
(cell.fragments.length > 0
|
||||
? Boolean(cell.fragments[visualIndex])
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
|
||||
visualIndex * model.lineHeight),
|
||||
);
|
||||
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
|
||||
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
||||
const fragment =
|
||||
cell.fragments.length > 0
|
||||
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight);
|
||||
const anchorY = row.top + fragment.top;
|
||||
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
|
||||
const anchorY = row.top + (fragment?.top ?? 0);
|
||||
return {
|
||||
kind: "text",
|
||||
path: file.path,
|
||||
sourceIdentity: cell.sourceIdentity,
|
||||
cellOffset: fragment.start,
|
||||
cellOffset: fragment?.start ?? 0,
|
||||
viewportDelta: anchorY - scrollTop,
|
||||
};
|
||||
}
|
||||
@@ -921,10 +831,8 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
||||
const fragment =
|
||||
cell.fragments.find(
|
||||
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
||||
) ??
|
||||
cell.fragments.at(-1) ??
|
||||
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
|
||||
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
|
||||
) ?? cell.fragments.at(-1);
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
||||
}
|
||||
|
||||
export function resolveRelayoutScrollTop(
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from "./horizontal-offsets";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
resolveRelayoutScrollTop,
|
||||
retainReusableModels,
|
||||
shouldApplyRelayoutScroll,
|
||||
@@ -139,9 +138,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTop.get())
|
||||
: null,
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
@@ -152,7 +148,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}, [
|
||||
measurement,
|
||||
fileWindowTop,
|
||||
scrollTop,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
|
||||
@@ -28,20 +28,10 @@ import {
|
||||
} from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
FILE_HEADER_HEIGHT,
|
||||
resolveRelayoutScrollTop,
|
||||
} from "./model";
|
||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import {
|
||||
createMeasuredAdvances,
|
||||
createAdditiveAdvances,
|
||||
createChunkedWidthMeasurer,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import { createMeasuredAdvances } from "./text-measurement";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
import type {
|
||||
DiffHit,
|
||||
@@ -193,9 +183,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTopRef.current)
|
||||
: null,
|
||||
});
|
||||
return next;
|
||||
}, [
|
||||
@@ -1179,13 +1166,5 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
||||
};
|
||||
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
|
||||
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
};
|
||||
return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) };
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@ export interface DiffCell {
|
||||
lineNumber: number | null;
|
||||
tokens: DiffTokenRun[];
|
||||
fragments: DiffFragment[];
|
||||
/** Monospace columns per visual line until this cell is measured. */
|
||||
estimatedWrapColumns?: number;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
}
|
||||
@@ -187,8 +185,6 @@ export interface BuildDiffDocumentModelInput {
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
materializationWindow?: { top: number; height: number };
|
||||
/** Also measure the anchored row so relayout can restore its exact fragment. */
|
||||
materializationAnchor?: DiffScrollAnchor | null;
|
||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||
reuseFrom?: readonly DiffDocumentModel[];
|
||||
}
|
||||
|
||||
@@ -95,37 +95,6 @@ describe("diff document workspace cache", () => {
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("materializes a new anchor when the viewport window is unchanged", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const { measureText } = countingMeasurer();
|
||||
const input = modelInput([source], measureText, {
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: 30, height: 54 },
|
||||
});
|
||||
const first = cache.buildModel(input);
|
||||
const anchored = cache.buildModel({
|
||||
...input,
|
||||
materializationAnchor: {
|
||||
kind: "text",
|
||||
path: source.path,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" },
|
||||
cellOffset: 90,
|
||||
viewportDelta: -2,
|
||||
},
|
||||
});
|
||||
const firstRow = first.rows[50]!;
|
||||
const anchoredRow = anchored.rows[50]!;
|
||||
expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0);
|
||||
expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
|
||||
@@ -128,9 +128,7 @@ function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): s
|
||||
const materializationWindow = input.materializationWindow
|
||||
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||
: null;
|
||||
const anchor = input.materializationAnchor;
|
||||
const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]);
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
||||
}
|
||||
|
||||
function typographyKey(typography: DiffTypography): string {
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { page } from "vitest/browser";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
interface TerminalView {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: Terminal;
|
||||
}
|
||||
|
||||
interface RowImage {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
const views: TerminalView[] = [];
|
||||
const header = "HEADER_0123456789 한글 日本語";
|
||||
|
||||
function mountTerminal(left: number): TerminalView {
|
||||
const root = document.createElement("div");
|
||||
Object.assign(root.style, {
|
||||
position: "fixed",
|
||||
left: `${left}px`,
|
||||
top: "0",
|
||||
width: "440px",
|
||||
height: "360px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
Object.assign(host.style, { width: "100%", height: "100%" });
|
||||
root.appendChild(host);
|
||||
document.body.appendChild(root);
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" },
|
||||
});
|
||||
const terminal = window.__paseoTerminal;
|
||||
if (!terminal) throw new Error("Terminal did not mount");
|
||||
const view = { root, host, runtime, terminal };
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function write(view: TerminalView, text: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
function webglCanvas(view: TerminalView): HTMLCanvasElement {
|
||||
for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) {
|
||||
if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas;
|
||||
}
|
||||
throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption");
|
||||
}
|
||||
|
||||
async function mountPair() {
|
||||
await page.viewport(1000, 600);
|
||||
const active = mountTerminal(0);
|
||||
const sibling = mountTerminal(460);
|
||||
await document.fonts.ready;
|
||||
await expect
|
||||
.poll(() => active.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
webglCanvas(active);
|
||||
webglCanvas(sibling);
|
||||
await write(active, "\x1b[?25l");
|
||||
await write(sibling, "\x1b[?25l");
|
||||
return { active, sibling };
|
||||
}
|
||||
|
||||
function captureHeader(input: { view: TerminalView; output?: string }): Promise<RowImage> {
|
||||
const { view, output } = input;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
rendered.dispose();
|
||||
reject(new Error("Terminal did not render within 2 seconds"));
|
||||
}, 2000);
|
||||
const rendered = view.terminal.onRender(() => {
|
||||
rendered.dispose();
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
// Copy during onRender, before WebGL discards its default drawing buffer.
|
||||
const source = webglCanvas(view);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = source.width;
|
||||
canvas.height = Math.round(source.height / view.terminal.rows);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D pixel capture is unavailable");
|
||||
context.drawImage(source, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
resolve({ width: canvas.width, height: canvas.height, pixels });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1);
|
||||
else view.runtime.write({ data: encodeTerminalOutput(output) });
|
||||
});
|
||||
}
|
||||
|
||||
function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void {
|
||||
const { expected, actual, label } = input;
|
||||
expect(actual.width, label).toBe(expected.width);
|
||||
expect(actual.height, label).toBe(expected.height);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < expected.pixels.length; index += 1) {
|
||||
if (expected.pixels[index] !== actual.pixels[index]) changed += 1;
|
||||
}
|
||||
expect(changed, label).toBe(0);
|
||||
}
|
||||
|
||||
function coloredOutput(input: { cells: number; offset: number }): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let output = "";
|
||||
for (let index = 0; index < input.cells; index += 1) {
|
||||
const value = index + input.offset;
|
||||
const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`;
|
||||
const background = (value * 7) % 256;
|
||||
output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`;
|
||||
}
|
||||
return `${output}\x1b[0m`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) {
|
||||
view.runtime.unmount();
|
||||
view.root.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
await write(sibling, header);
|
||||
const before = await captureHeader({ view: sibling });
|
||||
const visibleChannels = before.pixels.filter(
|
||||
(channel, index) => index % 4 !== 3 && channel > 100,
|
||||
);
|
||||
expect(visibleChannels.length).toBeGreaterThan(100);
|
||||
|
||||
active.terminal.clearTextureAtlas();
|
||||
await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) });
|
||||
const after = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1Hx`,
|
||||
});
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" });
|
||||
});
|
||||
|
||||
it("preserves both headers while ordinary output grows the shared glyph atlas", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
const cells = active.terminal.cols * (active.terminal.rows - 2);
|
||||
// Startup glyphs can stay on an unmerged page and hide this bug. Allocate the
|
||||
// reference after some output, as a real long-running TUI does.
|
||||
for (let batch = 0; batch < 16; batch += 1) {
|
||||
await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`,
|
||||
});
|
||||
}
|
||||
const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`;
|
||||
await write(active, lateHeader);
|
||||
await write(sibling, lateHeader);
|
||||
const activeBefore = await captureHeader({ view: active });
|
||||
const siblingBefore = await captureHeader({ view: sibling });
|
||||
|
||||
for (let batch = 0; batch < 256; batch += 1) {
|
||||
const activeFrame = await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`,
|
||||
});
|
||||
const siblingFrame = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`,
|
||||
});
|
||||
expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({
|
||||
expected: activeBefore,
|
||||
actual: activeFrame,
|
||||
label: `active frame ${batch}`,
|
||||
});
|
||||
expectSamePixels({
|
||||
expected: siblingBefore,
|
||||
actual: siblingFrame,
|
||||
label: `sibling frame ${batch}`,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
@@ -44,7 +44,9 @@ export function useOverviewSummaryLabel(
|
||||
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
|
||||
);
|
||||
}
|
||||
if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase());
|
||||
// Reasoning stays in the details; it is not an action in a work summary.
|
||||
// Keep a label for a group containing only reasoning.
|
||||
if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought");
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
|
||||
@@ -96,10 +96,7 @@ export default defineConfig({
|
||||
include: ["src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright({
|
||||
// Exercise real WebGL glyph rendering on GPU-less CI runners.
|
||||
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
|
||||
}),
|
||||
provider: playwright(),
|
||||
headless: true,
|
||||
connectTimeout: 180_000,
|
||||
instances: [{ browser: "chromium" }],
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
import { materializeProviderImage } from "./provider-image-output.js";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-posix-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("writes image attachments under a private directory in the paseo home", async () => {
|
||||
test("writes image attachments under a private temp directory", async () => {
|
||||
const materialized = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const attachmentDir = path.dirname(materialized.path);
|
||||
|
||||
// Exactly "paseo-attachments" — no mkdtemp suffix. The old per-process temp
|
||||
// directory is what let the OS reap these images out from under the app.
|
||||
expect(path.basename(attachmentDir)).toBe("paseo-attachments");
|
||||
expect(attachmentDir).toBe(path.join(testHome as string, "paseo-attachments"));
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
try {
|
||||
expect(path.basename(attachmentDir)).toMatch(/^paseo-attachments-/);
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
rmSync(attachmentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_PROVIDER_IMAGE_BYTES,
|
||||
isProviderImageMarkdown,
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "./provider-image-output.js";
|
||||
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
// Materialized images live under the paseo home now, so without this every test
|
||||
// run would read and delete the developer's real provider images.
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
|
||||
function useTempPaseoHome(): string {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
return testHome;
|
||||
}
|
||||
|
||||
function attachmentsDir(): string {
|
||||
if (!testHome) {
|
||||
throw new Error("useTempPaseoHome() must run before reading the attachments dir.");
|
||||
}
|
||||
return path.join(testHome, "paseo-attachments");
|
||||
}
|
||||
|
||||
async function renderImageMarkdown(imagePath: string): Promise<string> {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
@@ -121,95 +94,28 @@ describe("isProviderImageMarkdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
useTempPaseoHome();
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("materializeProviderImage", () => {
|
||||
test("recreates the private directory if the cached directory is removed", async () => {
|
||||
test("recreates the private temp directory if the cached directory is removed", async () => {
|
||||
const first = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const firstDir = path.dirname(first.path);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
|
||||
rmSync(path.dirname(first.path), { recursive: true, force: true });
|
||||
rmSync(firstDir, { recursive: true, force: true });
|
||||
|
||||
const second = await materializeProviderImage({
|
||||
data: "ZGVmNDU2",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const secondDir = path.dirname(second.path);
|
||||
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
});
|
||||
|
||||
test("stores images in a stable directory under the paseo home", async () => {
|
||||
const result = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// A fixed directory, not a per-process mkdtemp one — the old shape is what
|
||||
// let the OS reap these images out from under the app after ~3 days.
|
||||
expect(path.dirname(result.path)).toBe(attachmentsDir());
|
||||
expect(path.basename(path.dirname(result.path))).toBe("paseo-attachments");
|
||||
// Mode assertions live in the .posix suite: Windows ignores mkdir/writeFile
|
||||
// modes and reports 0o666, so asserting them here fails on that runner.
|
||||
});
|
||||
|
||||
test("is idempotent for the same bytes across daemon restarts", async () => {
|
||||
const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// Reload the module while retaining the files from the previous instance.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
const second = await restarted.materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(readdirSync(attachmentsDir())).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("emits markdown that isProviderImageMarkdown still recognizes", async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
throw new Error("Expected provider image output to render as assistant markdown.");
|
||||
try {
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
} finally {
|
||||
rmSync(secondDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
// The whole point of moving out of the temp dir is that nothing deletes these
|
||||
// behind the user's back. An age-based sweep would reintroduce that on a
|
||||
// longer fuse, because mtime cannot tell a referenced image from an abandoned
|
||||
// one and reading a transcript never touches the file.
|
||||
test("keeps images no matter how old they are", async () => {
|
||||
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
|
||||
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
|
||||
|
||||
// Reloading the module must not sweep images referenced by older transcripts.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
await restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
|
||||
|
||||
expect(existsSync(image.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { resolvePaseoHome } from "../../paseo-home.js";
|
||||
|
||||
export interface ProviderImageOutput {
|
||||
path?: string | null;
|
||||
@@ -18,13 +18,11 @@ export interface MaterializedProviderImage {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Keep this basename compatible with provider-image Markdown in existing timelines.
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX = `${PROVIDER_IMAGE_ATTACHMENT_DIR}-`;
|
||||
const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
|
||||
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
|
||||
|
||||
// Persist images across process lifetimes. Age-based cleanup would delete files
|
||||
// still referenced by transcripts; any future cleanup must account for references.
|
||||
let materializedImageAttachmentDir: string | null = null;
|
||||
// Serialize materializations: one decoder and one file operation at a time, including
|
||||
// concurrent history replay and live turns. A rejected operation must not poison the queue.
|
||||
@@ -39,14 +37,14 @@ async function getMaterializedImageAttachmentDir(): Promise<string> {
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
} catch {
|
||||
/* Attachment storage may have been removed between turns. */
|
||||
/* Temp storage may have been removed between turns. */
|
||||
}
|
||||
}
|
||||
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
|
||||
await fs.mkdir(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
|
||||
await fs.chmod(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
materializedImageAttachmentDir = dir;
|
||||
return dir;
|
||||
materializedImageAttachmentDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
|
||||
);
|
||||
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
function getImageExtension(mimeType: string): string {
|
||||
@@ -73,7 +71,6 @@ function getImageExtension(mimeType: string): string {
|
||||
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
|
||||
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
|
||||
|
||||
// Content-addressed filenames deduplicate repeated image bytes across restarts.
|
||||
export function materializeProviderImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
|
||||
Reference in New Issue
Block a user