Compare commits

..
Author SHA1 Message Date
Mohamed Boudra da3472fe51 fix(app): preserve the reader when older pages extend tool groups
Resolve grouped calls and folded turns through one presentation-owned row lookup. Keep retained group indexing off the streaming path and verify item-page group replacement against its failing baseline.
2026-10-27 12:32:24 +01:00
Mohamed Boudra e34aa277a2 fix(app): preserve reading position when older turns fold
Item-based history pages can complete and collapse the row at the reading position. Transfer its anchor to the folded turn host so reaching history start does not drain older pages. Also omit reasoning from action summaries and align sender attribution with its message bubble.
2026-10-27 11:57:50 +01:00
34 changed files with 598 additions and 1307 deletions
-20
View File
@@ -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
+9 -9
View File
@@ -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();
}
});
+20 -1
View File
@@ -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)
+2 -2
View File
@@ -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");
+51 -10
View File
@@ -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);
+16 -7
View File
@@ -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,
};
}
+1
View File
@@ -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;
}
+21 -113
View File
@@ -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]);
}
+1 -4
View File
@@ -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;