Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 9400a49af6 chore(release): cut 0.7.2 2026-09-02 02:07:34 +02:00
Mohamed Boudra e9612df862 docs: add 0.7.2 changelog 2026-09-02 01:57:25 +02:00
Mohamed Boudra 8dd4da6ee0 Keep mobile streaming and panel motion stable under JS stalls (#4190)
* fix(app): activate mobile panels after motion settles

Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion.

* perf(app): re-render only changed history rows

Every coalesced stream tick re-rendered all mounted history rows because the inverted FlatList recreates each cell with a new index and ref. Keep layout item identity for unchanged rows and memoize the row on it, and resolve tool-call groups through a stable event so the render callbacks stop churning.

* perf(app): count replica row bytes once

Budget accounting measured every stored row of a touched host on each persist through the Buffer polyfill. Cache the UTF-8 length per immutable row and count without materialising bytes.

* fix(app): settle mobile panels on position

Activity now follows the UI-thread position reaching the canonical anchor instead of an animation completion callback. A successful gesture keeps its finishing motion and the matching command adopts it; settlement and command acceptance may arrive in either order.

* fix(app): keep settled panel props across JS stalls

Turn off Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS. Its settled-props collector drops entries older than two seconds before React receives them, so a JS stall across a settle left the overlay display and backdrop opacity reverted on the next commit while the store held the new panel.

* fix(app): reconcile hidden keyboard end on iOS

Only a hidden end event resets the keyboard shift. A late visible end no longer resurrects a keyboard that already dismissed.

* fix(app): focus the replacement input through autoFocus after clearing

Clearing swaps the native input for a fresh instance. Focusing the doomed instance asked Android for the keyboard and then tore the focused view down, which cancelled the show; a JS focus command sent alongside the remount reached a view that was not attached yet and the IME ignored it. Focus and blur requested while the replacement is pending now decide the replacement's autoFocus, which native applies once the view is attached.

* fix(app): revise history rows in every viewport

The history row memo boundary bails out until its item identity changes, but only the native viewport cloned rows flagged by historyRowRevision. On web and desktop a history host whose tool-call group kept updating from the live head rendered stale. Move the revision into useRevisedHistoryRows and run both viewports' history through it. Regression test renders the web strategy with a memoized row and asserts only the revised host re-renders.
2026-09-02 00:52:44 +02:00
Mohamed Boudra 3998ea11d9 Keep large diffs responsive across web and mobile (#4174)
* perf(app): keep large diffs responsive

Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files.

* test(app): assert canvas diff header accessibility

* test(app): assert canvas diff header semantics
2026-09-01 23:52:11 +02:00
Mohamed Boudra ba54575ab7 Show completion times for hidden-prompt turns (#4170)
System-injected prompts have canonical turns but no visible user row, so completed timing was discarded despite an exact end timestamp. Preserve completion metadata with an explicitly unknown duration so the footer can show the truthful timestamp.
2026-09-01 23:24:20 +02:00
Mohamed Boudra 3e59adb4dc Add Claude Fable 5.1 to the model catalog (#4179)
* feat(claude): add Fable 5.1 model

Expose the new 1M-context model through the curated Claude catalog and normalize its dated and context-suffixed runtime IDs back to one canonical entry.

* fix(claude): group Fable models in catalog

* chore(release): cut 0.7.1

* fix(nix): refresh 0.7.1 dependency hash

* test(cli): follow package version in plugin scaffold

* test(cli): keep Claude catalog checks extensible
2026-09-01 21:51:51 +02:00
Mohamed Boudra 640b456915 docs: add 0.7.1 changelog 2026-09-01 20:59:27 +02:00
Mohamed Boudra dd5e8f95b1 Fix graceful daemon shutdown on Windows (#4168)
* test(server): probe supervised Windows cleanup

* test(server): report both cleanup failures

* fix(server): gracefully stop supervised workers
2026-09-01 19:59:03 +02:00
90 changed files with 3207 additions and 880 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.7.2 - 2026-09-02
### Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
### Improved
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
### Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
## 0.7.1 - 2026-09-01
### Added
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
## 0.7.0 - 2026-08-31
### Changed
+9
View File
@@ -31,6 +31,15 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
## Measuring
+19 -10
View File
@@ -30,10 +30,10 @@ The UI worklet owns transient motion:
- the active gesture's starting revision
- the last settled target
React publishes the active panel only after its motion reaches the final anchor. Retained content
never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted;
worklets reveal their retained overlays through native styles while React owns pointer events and
accessibility.
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
React owns pointer events and accessibility.
## Why one position
@@ -57,8 +57,10 @@ while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
motion immediately; the matching React command adopts that motion instead of restarting it. Position
settlement and command acceptance may arrive in either order. Activity changes after both agree on
the same target.
Manual gesture arbitration has two phases:
@@ -87,6 +89,12 @@ definition, no longer eligible to begin.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
animated props back to React through a collector that forgets entries older than two seconds. A JS
stall across a panel settle loses the final position, and the next React commit reverts the overlay
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
the collector in 4.4.3, which needs React Native 0.83.
- Gesture start and progress must not update React state. The retained overlay is already mounted and
offscreen; only the shared position and its derived native styles move during a drag.
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
@@ -100,8 +108,9 @@ definition, no longer eligible to begin.
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- `useIsMobilePanelActive` follows the settled target, not the requested target. Activation work may
begin only after the finishing animation completes; cancellation publishes nothing.
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
canonical target's anchor is the settlement signal; animation duration and completion callbacks
do not own activity. Cancellation publishes nothing.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
@@ -109,5 +118,5 @@ definition, no longer eligible to begin.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
a sequence there whenever ownership or ordering changes.
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
- Added declared build commands and monorepo source paths for Git-hosted plugins
Improved
- Reduced JS stalls while watching a streaming agent on mobile
Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-HFYBrCP62r6ZofazBIGSOnMhk8IIEmv+TFJMpqTIJQ8=
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0",
"version": "0.7.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/server": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0",
"version": "0.7.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0",
"version": "0.7.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0",
"@getpaseo/highlight": "0.7.0",
"@getpaseo/plugin": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0",
"version": "0.7.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.0",
"version": "0.7.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+246 -63
View File
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
return page.getByTestId("changes-file-tree").filter({ visible: true });
}
function diffHeaderForPath(panel: Locator, filePath: string): Locator {
return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/);
}
async function readFileIfPresent(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, "utf8");
@@ -261,12 +265,12 @@ test.afterEach(async () => {
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
@@ -302,7 +306,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
@@ -312,7 +316,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("changes file actions open below the right-click without a reserved kebab", async ({
@@ -322,9 +326,8 @@ test("changes file actions open below the right-click without a reserved kebab",
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none");
const deletedFileName = page.getByTestId("diff-file-1");
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
@@ -378,49 +381,109 @@ test("every interactive file header has the same hover feedback", async ({ page
const first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle");
const normalBackground = await first.evaluate(
(element) => getComputedStyle(element.parentElement!).backgroundColor,
);
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
)
.toBe("0px");
const canvas = page.getByTestId("git-diff-header-canvas");
const normalBackground = await headerCanvasPixel(canvas, first, 10);
await first.hover();
const hoverBackground = await first.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(hoverBackground).not.toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
await first.click();
await page.mouse.move(0, 0);
await expect(first).toHaveAttribute("aria-expanded", "false");
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
)
.toBe(normalBackground);
const [sharedBorder, secondTopBorderWidth] = await Promise.all([
first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor),
second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
]);
expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)");
expect(secondTopBorderWidth).toBe("0px");
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await first.hover();
await expect
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
await second.hover();
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
});
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
const headerCanvas = page.getByTestId("git-diff-header-canvas");
const header = page.getByTestId("diff-file-0-toggle");
const beforeImage = await headerCanvas.evaluate((element) =>
(element as HTMLCanvasElement).toDataURL(),
);
const beforeBounds = await header.boundingBox();
await horizontallyScrollFirstFile(page, 320);
await page.waitForTimeout(50);
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
beforeImage,
);
expect(await header.boundingBox()).toEqual(beforeBounds);
});
test("the outgoing sticky header hands off without a gap or overlap", async ({
page,
}, testInfo) => {
const workspace = await createWorkspaceWithStickyTransitionDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace);
const scroller = page.getByTestId("git-diff-scroll");
const first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle");
const secondTop = await second.evaluate((element) => {
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
if (!scrollElement) return Number.NaN;
return (
scrollElement.scrollTop +
element.getBoundingClientRect().top -
scrollElement.getBoundingClientRect().top
);
});
expect(secondTop).toBeGreaterThan(30);
await scroller.evaluate((element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, secondTop - 15);
await expect(first).toBeAttached();
await expect(second).toBeAttached();
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
scroller.boundingBox(),
first.boundingBox(),
second.boundingBox(),
]);
if (!scrollBounds || !firstBounds || !secondBounds) {
throw new Error("Sticky transition geometry is unavailable");
}
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
const transitionHits = await scroller.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return [14, 16].map(
(y) =>
document
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
);
});
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
await testInfo.attach("canvas-header-sticky-handoff", {
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
contentType: "image/png",
});
await scroller.evaluate((element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, secondTop);
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
)
.toBe(normalBackground);
await expect
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
.poll(async () => {
const bounds = await second.boundingBox();
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
})
.toBeCloseTo(0, 0);
});
test("changes context menus duplicate files and folders", async ({ page }) => {
@@ -475,7 +538,8 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
const tree = changesTree(page);
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
// Directories sort before files at every level, so the root note lands last
// even though "a-root-note.txt" compares below "src/..." as a whole string.
@@ -485,9 +549,17 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
// descendants of a collapsed folder, which would make the tree a subsequence
// of the diff rather than a match, and mask a real ordering difference.
await expect(treeNames).toHaveCount(expected.length);
await expect(diffNames).toHaveCount(expected.length);
await expect(diffHeaders).toHaveCount(expected.length);
await expect(treeNames).toHaveText(expected);
await expect(diffNames).toHaveText(expected);
const expectedHeaderNames = [
"src/zz-folder/nested/changed.ts, +1, -1",
"src/zz-folder/root.ts, +1, -1",
"src/use-mounted-tab-set.ts, +37, -1",
"a-root-note.txt, +1, -0",
];
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
}
});
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
@@ -586,9 +658,10 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const renamedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-renamed.ts" });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
@@ -615,9 +688,10 @@ test("discarding an untracked file removes it from the working tree", async ({ p
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const untrackedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-untracked.txt" });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
@@ -676,8 +750,12 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
"src/use-mounted-tab-set.ts, +37, -1",
);
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
"src/zz-deleted.ts, +0, -1",
);
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
@@ -712,16 +790,18 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
"src/use-mounted-tab-set.ts, +37, -1",
{ timeout: 30_000 },
);
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
"src/zz-deleted.ts, +0, -1",
);
});
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
@@ -980,6 +1060,29 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
});
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
page,
}) => {
test.setTimeout(180_000);
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace, 90_000);
const root = page.getByTestId("git-diff-canvas-root");
const scroller = page.getByTestId("git-diff-scroll");
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
});
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
page,
}) => {
@@ -1380,6 +1483,58 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
content: "export const value = 0;\n",
}));
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
await Promise.all(
files.map((file, index) =>
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
),
);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace> {
const source = Array.from(
{ length: 48 },
(_, index) => `export const value${index} = ${index};`,
).join("\n");
const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({
path: filePath,
content: `${source}\n`,
}));
const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files });
const changedSource = Array.from(
{ length: 48 },
(_, index) => `export const value${index} = ${index + 100};`,
).join("\n");
await Promise.all(
files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)),
);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
@@ -1389,11 +1544,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page);
}
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
async function openWorkspaceChangesSurface(
page: Page,
workspace: DirtyWorkspace,
timeout?: number,
): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await openChangesPanel(page, timeout);
}
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -1402,7 +1561,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page);
@@ -1701,6 +1860,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
await page.mouse.up();
}
async function headerCanvasPixel(
canvas: Locator,
header: Locator,
offsetY: number,
): Promise<string> {
const [canvasBounds, headerBounds] = await Promise.all([
canvas.boundingBox(),
header.boundingBox(),
]);
if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds");
return canvas.evaluate(
(element, point) => {
const target = element as HTMLCanvasElement;
const ratio = target.width / target.getBoundingClientRect().width;
const context = target.getContext("2d");
if (!context) throw new Error("Canvas header has no 2D context");
return Array.from(
context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data,
).join(",");
},
{ x: 4, y: headerBounds.y - canvasBounds.y + offsetY },
);
}
async function readDiffTypographyGeometry(page: Page): Promise<{
horizontalExtent: number;
canvasPixels: string;
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
}
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
const [header, content, name, stat] = await Promise.all([
const [header, canvas] = await Promise.all([
panel.getByTestId("diff-file-0").boundingBox(),
panel.getByTestId("diff-file-0-header-content").boundingBox(),
panel.getByTestId("diff-file-0-name").boundingBox(),
panel.getByTestId("diff-file-0-stat").boundingBox(),
panel.getByTestId("git-diff-header-canvas").boundingBox(),
]);
expect(header).not.toBeNull();
expect(content).not.toBeNull();
expect(name).not.toBeNull();
expect(stat).not.toBeNull();
expect(canvas).not.toBeNull();
expect(header!.height).toBeCloseTo(30, 0);
expect(content!.x).toBeCloseTo(header!.x, 0);
expect(content!.width).toBeCloseTo(header!.width, 0);
expect(name!.x - header!.x).toBeCloseTo(12, 0);
expect(name!.x + name!.width).toBeLessThan(stat!.x);
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
1,
);
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
expect(header!.x).toBeCloseTo(canvas!.x, 0);
expect(header!.width).toBeCloseTo(canvas!.width, 0);
expect(header!.y).toBeCloseTo(canvas!.y, 0);
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
}
+266 -23
View File
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const RUN_HEADER_COMPARISON = process.env.PASEO_DIFF_HEADER_COMPARISON === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT_PER_FILE = 1_200;
const MANY_FILE_COUNT = Number(process.env.PASEO_DIFF_MANY_FILE_COUNT ?? 2_000);
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
const POC_P95_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
);
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
const MANY_FILE_MAX_P95_FRAME_MS = 80;
diffPerfDescribe("Diff canvas performance", () => {
test("opening a review stays responsive in a large diff", async ({ page }) => {
@@ -58,7 +62,131 @@ diffPerfDescribe("Diff canvas performance", () => {
}
});
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
test("many small files keep canvas headers bounded and responsive", async ({
page,
}, testInfo) => {
test.setTimeout(300_000);
const repo = await createManyFileDiffRepository(MANY_FILE_COUNT);
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create many-file performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await page.setViewportSize({ width: 1400, height: 900 });
const openedAt = performance.now();
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("diff-file-0-toggle")).toBeVisible({ timeout: 30_000 });
const readyMs = performance.now() - openedAt;
const [initialShells, initialElements, paintKind] = await page
.getByTestId("git-diff-canvas-root")
.evaluate(
(root) =>
[
root.querySelectorAll('[data-diff-header="true"]').length,
root.querySelectorAll("*").length,
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
] as const,
);
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
const style = getComputedStyle(header);
const bounds = header.getBoundingClientRect();
const leaves = [];
for (const element of header.querySelectorAll<HTMLElement>("*")) {
if (element.children.length !== 0 || !element.textContent?.trim()) continue;
const leafStyle = getComputedStyle(element);
const leafBounds = element.getBoundingClientRect();
leaves.push({
text: element.textContent,
x: leafBounds.x - bounds.x,
y: leafBounds.y - bounds.y,
width: leafBounds.width,
height: leafBounds.height,
font: leafStyle.font,
lineHeight: leafStyle.lineHeight,
color: leafStyle.color,
});
}
return {
width: bounds.width,
height: bounds.height,
padding: style.padding,
border: style.border,
boxShadow: style.boxShadow,
leaves,
};
});
const headerCanvasMetrics = await page
.getByTestId("git-diff-header-canvas")
.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Header canvas has no 2D context");
const font = context.font;
return {
font,
nameWidth: context.measureText("file-0000.ts").width,
directoryWidth: context.measureText(" src").width,
};
})
.catch(() => null);
const capturePath = process.env.PASEO_DIFF_HEADER_CAPTURE_PATH;
if (capturePath) {
await writeFile(capturePath, await page.getByTestId("git-diff-canvas-root").screenshot());
}
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startRendererInstrumentation(page);
const benchmark = await benchmarkManyFileHeaderScrolling(page);
const renderer = await readRendererInstrumentation(page);
const heap = await collectHeapUsage(cdp);
await attachPerformanceReport(testInfo, {
scenario: "many-small-files",
sourceFiles: MANY_FILE_COUNT,
cpuSlowdown: CPU_SLOWDOWN,
readyMs,
initialShells,
initialElements,
paintKind,
headerDomMetrics,
headerCanvasMetrics,
benchmark,
renderer,
heap,
});
console.log(
`[perf] Many-file headers: ${JSON.stringify({ readyMs, initialShells, initialElements, paintKind, headerDomMetrics, headerCanvasMetrics, benchmark, renderer, heap })}`,
);
if (!RUN_HEADER_COMPARISON) {
expect(benchmark.maximumShells).toBeLessThan(120);
expect(benchmark.blankHeaderFrames).toBe(0);
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
expect(Math.abs(benchmark.maximumScrollTop - benchmark.endScrollTop)).toBeLessThanOrEqual(
1,
);
expect(renderer.reactCommits).toBeLessThanOrEqual(80);
}
} finally {
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("bounded canvases stay filled through continuous fast scrolling", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const repo = await createLargeDiffRepository();
const client = await connectSeedClient();
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
await expectBoundedCanvasSurfaces(page);
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startChromiumTrace(cdp);
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
const renderer = await readRendererInstrumentation(page);
const measuredCycles = scrollReports.slice(1);
console.log(
`[perf] Large-file canvas: ${JSON.stringify({ scrollReports, heapUsage, renderer })}`,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
testInfo: TestInfo,
name: "wrapped" | "split",
) {
await expectSingleCanvasSurface(page);
await expectBoundedCanvasSurfaces(page);
await startRendererInstrumentation(page);
const scrollReports = [];
const heapUsage = [];
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
const renderer = await readRendererInstrumentation(page);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
return {
scrollReports,
heapUsage,
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
return repo;
}
async function createManyFileDiffRepository(fileCount: number) {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
content: "export const value = 0;\n",
}));
const repo = await createTempGitRepo("diff-perf-many-files-", { files });
await Promise.all(
files.map((file, index) =>
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
),
);
return repo;
}
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
return performance.now() - startedAt;
}
async function expectSingleCanvasSurface(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(1);
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(2);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page
.getByTestId("git-diff-canvas-root")
@@ -377,6 +522,92 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
});
}
async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
medianFrameMs: number;
p95FrameMs: number;
maximumFrameMs: number;
maximumShells: number;
blankHeaderFrames: number;
endScrollTop: number;
maximumScrollTop: number;
}> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="git-diff-header-canvas"]',
);
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
const frameDurations: number[] = [];
let maximumShells = 0;
let blankHeaderFrames = 0;
const sampleHeader = () => {
maximumShells = Math.max(
maximumShells,
document.querySelectorAll('[data-diff-header="true"]').length,
);
if (canvas) {
const context = canvas.getContext("2d")!;
const ratio = canvas.width / canvas.getBoundingClientRect().width;
if (
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
) {
blankHeaderFrames += 1;
}
return;
}
const bounds = scroll.getBoundingClientRect();
if (
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
) {
blankHeaderFrames += 1;
}
};
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
const sweepStart = Math.min(
maximumScrollTop,
Math.max(0, maximumScrollTop * progress - scroll.clientHeight),
);
scroll.scrollTop = sweepStart;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
let previousFrame = performance.now();
for (let step = 1; step <= 24; step += 1) {
scroll.scrollTop = Math.min(
maximumScrollTop,
sweepStart + (scroll.clientHeight * step) / 24,
);
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const now = performance.now();
frameDurations.push(now - previousFrame);
previousFrame = now;
sampleHeader();
}
}
scroll.scrollTop = maximumScrollTop;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sampleHeader();
const ordered = [...frameDurations].sort((left, right) => left - right);
const percentile = (fraction: number) =>
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
return {
medianFrameMs: percentile(0.5),
p95FrameMs: percentile(0.95),
maximumFrameMs: Math.max(...frameDurations),
maximumShells,
blankHeaderFrames,
endScrollTop: scroll.scrollTop,
maximumScrollTop,
};
});
}
interface CapturedCanvasFrame {
scrollTop: number;
image: Buffer;
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
page: Page,
): Promise<void> {
const scroller = page.getByTestId("git-diff-scroll");
const first = page.getByTestId("diff-file-0-horizontal-scroll");
const second = page.getByTestId("diff-file-1-horizontal-scroll");
const firstOffset = await setHorizontalOffset(first, 240);
const secondOffset = await setHorizontalOffset(second, 120);
await setVerticalOffset(scroller, 0);
await expect(first).toBeAttached();
const firstOffset = await setHorizontalOffset(first, 40);
await setVerticalOffset(scroller, "end");
await expect(second).toBeAttached();
const secondOffset = await setHorizontalOffset(second, 20);
expect(firstOffset).not.toBe(secondOffset);
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await setVerticalOffset(scroller, 0);
await expect(first).toBeAttached();
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
await setVerticalOffset(scroller, "end");
await expect(second).toBeAttached();
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
}
async function setVerticalOffset(locator: Locator, offset: number | "end") {
await locator.evaluate(async (element, nextOffset) => {
element.scrollTop =
nextOffset === "end" ? element.scrollHeight - element.clientHeight : nextOffset;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}, offset);
}
async function setHorizontalOffset(locator: Locator, offset: number) {
return locator.evaluate(async (element, nextOffset) => {
element.scrollLeft = nextOffset;
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
// Workspace B (same directory): the SAME directory-backed content is
// visible even though the panel tabs belong to a different workspace.
@@ -79,8 +79,8 @@ test.describe("Same-directory workspaces", () => {
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
} finally {
await seeded.cleanup();
}
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
});
}
export async function openChangesPanel(page: Page): Promise<void> {
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
await openChangesTreePanel(page);
const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await expect(changedFile).toBeVisible({ timeout });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
timeout,
});
}
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.0",
"version": "0.7.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -162,5 +162,10 @@
"vitest": "^4.1.6",
"wrangler": "^4.105.0",
"ws": "^8.20.0"
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
}
}
}
@@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
@@ -0,0 +1,47 @@
import { useMemo } from "react";
import type { StreamItem } from "@/types/stream";
import type { StreamHistoryRowRevision } from "./strategy";
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
}
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
let variants = historyRowDisplayVariants.get(item);
if (!variants) {
variants = {};
historyRowDisplayVariants.set(item, variants);
}
const key = compact ? "compact" : "regular";
variants[key] ??= { ...item };
return variants[key];
}
// Item identity is the render signal for history rows: the row boundary in view.tsx bails out
// until its item changes. Every viewport runs its history through this hook so a history host
// whose tool-call group keeps updating from the live head, a group whose expanded state changed,
// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity,
// which is what limits a live update to the rows it touched.
export function useRevisedHistoryRows(
items: StreamItem[],
revision: StreamHistoryRowRevision | undefined,
): StreamItem[] {
const globalDisplayState = revision?.globalDisplayState ?? false;
const displayStateById = revision?.displayStateById;
const contentById = revision?.contentById;
const globallyRevisedRows = useMemo(
() => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)),
[items, globalDisplayState],
);
const displayStateRevisedRows = useMemo(
() => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)),
[globallyRevisedRows, displayStateById],
);
return useMemo(
() => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)),
[displayStateRevisedRows, contentById],
);
}
+17 -1
View File
@@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
@@ -366,6 +365,23 @@ describe("layoutStream", () => {
},
);
it("keeps layout item identity for rows whose layout did not change", () => {
const user = userMessage("u1", 1);
const first = toolCall("tool-1", 2);
const second = toolCall("tool-2", 3);
const before = layoutFor({ platform: "android", tail: [user, first, second] });
const third = toolCall("tool-3", 4);
const after = layoutFor({ platform: "android", tail: [user, first, second, third] });
// A newest-first list shifts every index, but only the rows next to the insertion change.
expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id));
expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id));
expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id));
expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false);
expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true);
});
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
+102 -55
View File
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence:
isToolSequenceItem(item) &&
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
frameOrder: input.frameOrder,
phase: input.phase,
};
function areTurnFooterHostsEqual(
left: TurnFooterHost | null,
right: TurnFooterHost | null,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return (
left.itemId === right.itemId &&
left.timing === right.timing &&
left.startIndex === right.startIndex &&
left.items === right.items
);
}
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
return (
previous.item === next.item &&
previous.aboveItem === next.aboveItem &&
previous.belowItem === next.belowItem &&
previous.gapBelow === next.gapBelow &&
previous.assistantSpacing === next.assistantSpacing &&
areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) &&
previous.toolSequence === next.toolSequence &&
previous.isFirstInUserGroup === next.isFirstInUserGroup &&
previous.isLastInUserGroup === next.isLastInUserGroup &&
previous.isLastInToolSequence === next.isLastInToolSequence &&
previous.frameOrder === next.frameOrder &&
previous.phase === next.phase
);
}
function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem {
const previous = previousLayoutItemByStreamItem.get(next.item);
if (previous && areLayoutItemsEquivalent(previous, next)) {
return previous;
}
previousLayoutItemByStreamItem.set(next.item, next);
return next;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
}
function layoutSegmentItem(
input: LayoutSegmentInput,
item: StreamItem,
index: number,
): StreamLayoutItem {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return shareLayoutItem({
item,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence:
isToolSequenceItem(item) &&
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
frameOrder: input.frameOrder,
phase: input.phase,
});
}
@@ -69,7 +69,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
startedAt: tail[2]?.timestamp,
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
@@ -176,7 +175,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
@@ -196,7 +194,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
};
const HISTORY_START_SETTLE_FRAMES = 2;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
}
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
let variants = historyRowDisplayVariants.get(item);
if (!variants) {
variants = {};
historyRowDisplayVariants.set(item, variants);
}
const key = compact ? "compact" : "regular";
variants[key] ??= { ...item };
return variants[key];
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
// Keep unchanged item identities intact so live updates only rerender rows
// whose projected content or local display state actually changed. A rare
// breakpoint change intentionally refreshes the whole history window.
const globallyRevisedHistoryRows = useMemo(() => {
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
}, [historyItems, historyRowRevision?.globalDisplayState]);
const displayStateHistoryRows = useMemo(
() =>
globallyRevisedHistoryRows.map((item) =>
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
),
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
);
const historyRows = useMemo(
() =>
displayStateHistoryRows.map((item) =>
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("rerenders only the history row whose content revision changed", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
const host = userMessage(1);
const other = userMessage(2);
const renderCountById = new Map<string, number>();
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
return <div>{item.id}</div>;
});
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [host, other],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: {
...createRenderers(vi.fn()),
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
},
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
const unrevised = {
contentById: new Set<string>(),
displayStateById: new Set<string>(),
globalDisplayState: false,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
});
expect(renderCountById.get(host.id)).toBe(1);
expect(renderCountById.get(other.id)).toBe(1);
act(() => {
root?.render(
strategy.render({
...renderInput,
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
}),
);
});
expect(renderCountById.get(host.id)).toBe(2);
expect(renderCountById.get(other.id)).toBe(1);
});
it("keeps a row mounted when it moves from the live head into mounted history", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
+12 -1
View File
@@ -20,6 +20,7 @@ import {
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments,
segments: inputSegments,
historyRowRevision,
liveHeadRowRevision,
boundary,
renderers,
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
inputSegments.historyVirtualized,
historyRowRevision,
);
const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision);
const segments = useMemo(
() => ({ ...inputSegments, historyVirtualized, historyMounted }),
[historyMounted, historyVirtualized, inputSegments],
);
const isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
assistant("heartbeat-2-reply", 6, "turn-3"),
];
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
});
});
it.each([
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
startedAt: at(1),
completedAt: at(9),
durationMs: 8000,
});
+30 -3
View File
@@ -220,6 +220,22 @@ function renderListEmptyComponent(input: {
);
}
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const HistoryStreamRow = memo(function HistoryStreamRow({
layoutItem,
renderStreamItem,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}) {
return <>{renderStreamItem(layoutItem)}</>;
});
function renderHistoryStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
@@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: {
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
return (
<HistoryStreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
}
function renderLiveHeadStreamItem(input: {
@@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
const getToolCallGroup = useStableEvent((hostId: string) =>
projectedToolCalls.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = projectedToolCalls.groupsByHostId.get(item.id);
const group = getToolCallGroup(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
@@ -818,8 +845,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
},
[
projectedToolCalls.groupsByHostId,
expandedToolCallGroupIds,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
],
+1 -9
View File
@@ -1,5 +1,6 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { formatDiffCount } from "@/git/file-header-presentation";
interface DiffStatProps {
additions: number;
@@ -7,15 +8,6 @@ interface DiffStatProps {
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
+13 -10
View File
@@ -578,7 +578,7 @@ export const UserMessage = memo(function UserMessage({
interface AssistantTurnFooterProps {
getContent: () => string;
completedAt?: Date;
durationMs?: number;
durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
}
@@ -616,9 +616,8 @@ const TIMESTAMP_REVEAL_MS = 3000;
/**
* Footer rendered next to the copy button at the end of an assistant turn.
* Always shows the turn duration; swaps to the end timestamp on hover (web)
* or tap (native). The hidden sizer keeps the label width stable while the
* visible text swaps.
* Shows the turn duration and swaps to the end timestamp when both are known.
* A turn without a visible start shows its end timestamp directly.
*/
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent,
@@ -640,7 +639,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
}, []);
const durationLabel = useMemo(
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
() =>
durationMs !== undefined && durationMs !== null
? `Worked for ${formatDuration(durationMs)}`
: "",
[durationMs],
);
const timestampLabel = useMemo(
@@ -648,7 +650,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
[completedAt],
);
const canSwap = Boolean(timestampLabel);
const primaryLabel = durationLabel || timestampLabel;
const canSwap = Boolean(durationLabel && timestampLabel);
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
const handleHoverIn = useCallback(() => setHovered(true), []);
@@ -679,22 +682,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? (
{primaryLabel ? (
<Pressable
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : durationLabel}
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
</Pressable>
@@ -131,6 +131,46 @@ describe("EditingTextInputNative", () => {
expect(document.activeElement).toBe(container?.querySelector("input"));
});
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
});
const originalInput = container?.querySelector("input");
if (!originalInput) throw new Error("Expected native input");
const originalFocus = vi.spyOn(originalInput, "focus");
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.focus();
});
const replacementInput = container?.querySelector("input");
expect(replacementInput).not.toBe(originalInput);
expect(originalFocus).not.toHaveBeenCalled();
expect(document.activeElement).toBe(replacementInput);
});
it("drops a pending focus restore when blur is requested before a cleared input remounts", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
});
const originalInput = container?.querySelector("input");
if (!originalInput) throw new Error("Expected native input");
Object.assign(originalInput, { isFocused: () => true });
originalInput.focus();
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.blur();
});
expect(document.activeElement).not.toBe(container?.querySelector("input"));
});
it("updates textRef and text when replaceText receives non-empty text", () => {
const handleRef = createRef<EditingTextInputHandle>();
@@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
const restoreFocusAfterResetRef = useRef(false);
const [nativeInputRevision, setNativeInputRevision] = useState(0);
// Clearing swaps the native input for a fresh instance (see replaceText).
// Until React commits that swap, `inputRef` still points at the doomed
// instance: focusing it asks Android for the keyboard and then tears the
// focused view down, which cancels the show. Fabric also runs view
// commands before mount items, so a focus command sent alongside the swap
// reaches a view that is not attached yet and the IME ignores it. Focus is
// therefore carried by the replacement's `autoFocus`, which native applies
// once the view is attached.
const isAwaitingReplacementRef = useRef(false);
const [replacement, setReplacement] = useState({ revision: 0, autoFocus: false });
const assignInputRef = useCallback((input: NativeInput | null) => {
inputRef.current = input;
if (!input || !restoreFocusAfterResetRef.current) return;
restoreFocusAfterResetRef.current = false;
input.focus();
if (input) isAwaitingReplacementRef.current = false;
}, []);
const setReplacementFocus = useCallback((autoFocus: boolean) => {
setReplacement((current) => ({ ...current, autoFocus }));
}, []);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
focus: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (nextText === "") {
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false;
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
} else {
inputRef.current?.clear?.();
}
setNativeInputRevision((revision) => revision + 1);
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
return;
}
if (inputRef.current?.replaceText) {
@@ -93,11 +116,14 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
[onPasteError, onPasteImages],
);
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
if (onPasteImages || onPasteError) {
return (
<PasteInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return (
<BottomSheetTextInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as unknown as React.Ref<never>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return (
<TextInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<TextInput>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -9,6 +9,8 @@ interface DocumentFileHeaderProps {
mode: DiffDocumentProps["mode"];
onToggleFile: (path: string) => void;
onSelectPath: (path: string) => void;
canvasRendered?: boolean;
onActiveChange?: (active: boolean) => void;
}
export const DocumentFileHeader = memo(function DocumentFileHeader({
@@ -17,6 +19,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
mode,
onToggleFile,
onSelectPath,
canvasRendered = false,
onActiveChange,
}: DocumentFileHeaderProps) {
const activate = useCallback(
(path: string) => {
@@ -47,6 +51,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
onDuplicate={working?.onDuplicate}
onRevert={working?.onRevert}
testID={`diff-file-${file.fileIndex}`}
canvasRendered={canvasRendered}
onActiveChange={onActiveChange}
/>
);
}, documentFileHeaderPropsEqual);
@@ -84,6 +90,8 @@ function documentFileHeaderIdentityMatches(
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
previous.onToggleFile !== next.onToggleFile ||
previous.onSelectPath !== next.onSelectPath ||
previous.canvasRendered !== next.canvasRendered ||
previous.onActiveChange !== next.onActiveChange ||
previous.mode.kind !== next.mode.kind
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
diffHeaderViewportKey,
diffInteractionWindowTop,
resolveVisibleFileSections,
} from "./header-layout";
import type { DiffFileSection } from "./types";
describe("diff header viewport", () => {
it("keeps a many-file document bounded by the viewport", () => {
const files = Array.from({ length: 2_000 }, (_, index) => collapsedFile(index));
const window = resolveVisibleFileSections({
files,
scrollTop: 30_000,
viewportHeight: 600,
overscan: 600,
});
expect(window.files.length).toBeLessThanOrEqual(61);
expect(window.sticky?.file.fileIndex).toBe(1_000);
expect(window.sticky?.y).toBe(0);
});
it("pins an expanded file whose original header is outside overscan", () => {
const files = [expandedFile(0, 4_000), collapsedFile(1, 4_030)];
const window = resolveVisibleFileSections({
files,
scrollTop: 2_000,
viewportHeight: 600,
overscan: 600,
});
expect(window.files.map((file) => file.path)).toEqual(["file-0.ts"]);
expect(window.sticky).toEqual({ file: files[0], y: 0 });
});
it("pushes the pinned header out one pixel at a time", () => {
const files = [expandedFile(0, 100), collapsedFile(1, 130)];
expect(
resolveVisibleFileSections({ files, scrollTop: 129, viewportHeight: 100, overscan: 0 }).sticky
?.y,
).toBe(-29);
expect(
resolveVisibleFileSections({ files, scrollTop: 130, viewportHeight: 100, overscan: 0 }).sticky
?.y,
).toBe(0);
});
it("reuses a fixed sticky header until its visible layout changes", () => {
const files = [section(0, 0, 4_000, false), section(1, 4_000, 4_030, true)];
const keyAt = (scrollTop: number) =>
diffHeaderViewportKey({ files, scrollTop, viewportHeight: 600, activePath: null });
expect(keyAt(100)).toBe(keyAt(2_000));
expect(keyAt(3_985)).not.toBe(keyAt(2_000));
});
});
describe("diff interaction window", () => {
it("retains one shell window across two viewport heights", () => {
expect(diffInteractionWindowTop(0, 500)).toBe(0);
expect(diffInteractionWindowTop(999, 500)).toBe(0);
expect(diffInteractionWindowTop(1_000, 500)).toBe(1_000);
});
});
function collapsedFile(fileIndex: number, top = fileIndex * 30): DiffFileSection {
return section(fileIndex, top, top + 30, true);
}
function expandedFile(fileIndex: number, bodyHeight: number): DiffFileSection {
const top = fileIndex === 0 ? 0 : bodyHeight - 30;
return section(fileIndex, top, top + 30 + bodyHeight, false);
}
function section(
fileIndex: number,
top: number,
bottom: number,
isCollapsed: boolean,
): DiffFileSection {
const path = `file-${fileIndex}.ts`;
return {
file: {
path,
oldPath: undefined,
additions: 1,
deletions: 1,
isNew: false,
isDeleted: false,
hunks: [],
},
fileIndex,
path,
top,
headerHeight: 30,
bodyTop: top + 30,
bodyHeight: bottom - top - 30,
bottom,
gutterWidth: 30,
contentWidth: 300,
rowStart: 0,
rowEnd: 0,
isCollapsed,
};
}
@@ -0,0 +1,76 @@
import type { DiffFileSection } from "./types";
export interface VisibleDiffFileSections {
files: DiffFileSection[];
sticky: { file: DiffFileSection; y: number } | null;
}
export function diffInteractionWindowTop(scrollTop: number, viewportHeight: number): number {
"worklet";
const bucketHeight = Math.max(1, viewportHeight * 2);
return Math.floor(Math.max(0, scrollTop) / bucketHeight) * bucketHeight;
}
export function diffMaterializationWindow(
windowTop: number,
viewportHeight: number,
): { top: number; height: number } {
return {
top: Math.max(0, windowTop - viewportHeight * 2),
height: viewportHeight * 5,
};
}
export function diffHeaderViewportKey(input: {
files: readonly DiffFileSection[];
scrollTop: number;
viewportHeight: number;
activePath: string | null;
}): string {
const window = resolveVisibleFileSections({ ...input, overscan: 0 });
const headers = window.files
.filter((file) => file !== window.sticky?.file)
.map((file) => `${file.fileIndex}:${file.top - input.scrollTop}`);
if (window.sticky) headers.push(`${window.sticky.file.fileIndex}:${window.sticky.y}`);
return `${input.activePath ?? ""}|${headers.join(",")}`;
}
export function resolveVisibleFileSections(input: {
files: readonly DiffFileSection[];
scrollTop: number;
viewportHeight: number;
overscan: number;
}): VisibleDiffFileSections {
if (input.files.length === 0 || input.viewportHeight <= 0) {
return { files: [], sticky: null };
}
const visibleTop = Math.max(0, input.scrollTop - input.overscan);
const visibleBottom = input.scrollTop + input.viewportHeight + input.overscan;
const start = firstFileWhoseEndExceeds(input.files, visibleTop);
const files: DiffFileSection[] = [];
for (let index = start; index < input.files.length; index += 1) {
const file = input.files[index];
if (!file || file.top >= visibleBottom) break;
files.push(file);
}
const stickyFile = input.files[firstFileWhoseEndExceeds(input.files, input.scrollTop)] ?? null;
const sticky = stickyFile
? {
file: stickyFile,
y: Math.min(0, stickyFile.bottom - input.scrollTop - stickyFile.headerHeight),
}
: null;
return { files, sticky };
}
function firstFileWhoseEndExceeds(files: readonly DiffFileSection[], offset: number): number {
let low = 0;
let high = files.length;
while (low < high) {
const middle = (low + high) >>> 1;
const file = files[middle];
if (file && file.bottom <= offset) low = middle + 1;
else high = middle;
}
return low;
}
@@ -147,6 +147,11 @@ function buildModel(changedContent: string, overrides: Partial<BuildDiffDocument
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
+7 -1
View File
@@ -3,12 +3,13 @@ import { withUnistyles } from "react-native-unistyles";
import { RenderProfile } from "@/utils/render-profiler";
import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types";
import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
type ThemedDiffDocumentProps = DiffDocumentProps & {
palette: DiffPalette;
headerTypography: DiffHeaderTypography;
};
const EMPTY_PATHS: string[] = [];
@@ -45,6 +46,11 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
palette: createDiffPalette(theme),
headerTypography: {
family: theme.fontFamily.ui,
size: theme.fontSize.base,
statSize: theme.fontSize.sm,
},
}));
export function DiffDocument(props: DiffDocumentProps) {
@@ -78,6 +78,11 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -58,6 +58,11 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -446,8 +451,108 @@ describe("diff document model", () => {
expect(model.files[0]?.contentWidth).toBe(464);
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224);
});
it("builds exact unwrapped scroll geometry without measuring offscreen files", () => {
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
let measurementCount = 0;
const countingMeasurer: TextMeasurer = {
measure(text) {
measurementCount += 1;
return Array.from(text).length * 10;
},
};
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
const firstFileWindow = buildDiffDocumentModel(
input({
files,
wrapLines: false,
measureText: countingMeasurer,
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
}),
);
expect(firstFileWindow.height).toBe(eager.height);
expect(firstFileWindow.files.map((entry) => [entry.top, entry.bottom])).toEqual(
eager.files.map((entry) => [entry.top, entry.bottom]),
);
expect(firstFileWindow.rows[firstFileWindow.files[0]!.rowStart]).toMatchObject({
kind: "line",
});
expect(firstFileWindow.rows[firstFileWindow.files[1]!.rowStart]).toMatchObject({
kind: "line",
});
expect(measuredFragments(firstFileWindow, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(firstFileWindow, "b.ts")).toBe(0);
expect(measuredFragments(firstFileWindow, "c.ts")).toBe(0);
expect(measurementCount).toBeGreaterThan(0);
});
it("retains measured files while materializing a later unwrapped window", () => {
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
const first = buildDiffDocumentModel(
input({
files,
wrapLines: false,
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
}),
);
const lastFile = eager.files[2]!;
const last = buildDiffDocumentModel(
input({
files,
wrapLines: false,
materializationWindow: { top: lastFile.top, height: lastFile.bottom - lastFile.top },
reuseFrom: [first],
}),
);
expect(measuredFragments(last, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(last, "b.ts")).toBe(0);
expect(measuredFragments(last, "c.ts")).toBeGreaterThan(0);
expect(last.height).toBe(eager.height);
expect(firstLine(last, "a.ts")).toBe(firstLine(first, "a.ts"));
expect(firstLine(last, "b.ts")).toBe(firstLine(first, "b.ts"));
expect(firstLineCells(last, "a.ts")).toBe(firstLineCells(first, "a.ts"));
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
});
it("keeps wrapped geometry fully measured", () => {
const model = buildDiffDocumentModel(
input({
files: [file("a.ts"), file("b.ts")],
wrapLines: true,
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
}),
);
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
});
});
function measuredFragments(model: ReturnType<typeof buildDiffDocumentModel>, path: string): number {
const fileSection = model.files.find((entry) => entry.path === path);
if (!fileSection) throw new Error(`Expected ${path}`);
return model.rows
.slice(fileSection.rowStart, fileSection.rowEnd)
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
}
function firstLineCells(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
return firstLine(model, path).cells;
}
function firstLine(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
const fileSection = model.files.find((entry) => entry.path === path);
if (!fileSection) throw new Error(`Expected ${path}`);
const row = model.rows[fileSection.rowStart];
if (!row || row.kind !== "line") throw new Error(`Expected a line in ${path}`);
return row;
}
function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
return {
measure,
+351 -92
View File
@@ -37,9 +37,33 @@ interface CellSource {
sourceIdentity: DiffSourceIdentity;
}
interface GeometryLine {
cells: DiffLineRow["cells"];
reviewHeight: number;
height: number;
}
interface ReusableModelIndex {
model: DiffDocumentModel;
filesBySource: Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>;
measuredFiles: Set<DiffFileSection>;
}
interface ReusableFileRows {
model: DiffDocumentModel;
file: DiffFileSection;
}
interface FileRowsResult {
bottom: number;
maximumHorizontalOverflow: number;
}
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
const rows: DiffRow[] = [];
const files: DiffFileSection[] = [];
const reusableModels = indexReusableModels(input.reuseFrom);
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
let documentTop = 0;
for (const [fileIndex, file] of input.files.entries()) {
@@ -51,96 +75,40 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
let maximumHorizontalOverflow = 0;
const reusableModel = input.reuseFrom?.find((candidate) =>
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
);
const reusableFile = reusableModel?.files.find(
(candidate) => candidate.file === file && !candidate.isCollapsed,
);
const reusable = findReusableFileRows({
indexes: reusableModels,
buildInput: input,
sourceFile: file,
fileTop,
bodyTop,
});
const reusableModel = reusable?.model;
const reusableFile = reusable?.file;
if (!isCollapsed && reusableFile) {
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
for (const reusedRow of reusedRows) {
if (reusedRow.kind === "line") {
const reviewHeight = reviewHeightForCells(reusedRow.cells, input);
const textHeight = reusedRow.height - reusedRow.reviewHeight;
const height = textHeight + reviewHeight;
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
height,
reviewHeight,
});
documentTop += height;
continue;
}
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
});
documentTop += reusedRow.height;
}
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
documentTop += DIFF_BODY_BORDER_HEIGHT;
} else if (!isCollapsed) {
if (file.status === "binary" || file.status === "too_large") {
const height = input.typography.lineHeight + 24;
rows.push({
kind: "status",
index: rows.length,
fileIndex,
path: file.path,
top: documentTop,
height,
label: file.status === "binary" ? input.labels.binary : input.labels.tooLarge,
});
documentTop += height;
} else {
const sources = lineSources(file, input.layout);
for (const sourceCells of sources) {
const columnCount = sourceCells.length;
const columnWidth = input.viewportWidth / columnCount;
const availableWidth = Math.max(
input.measureText.measure("M"),
columnWidth - gutterWidth - CODE_HORIZONTAL_PADDING,
);
const cells = sourceCells.map((source) => {
if (!source) return null;
const cell = measureCell({
source,
availableWidth,
if (!isCollapsed) {
const result =
reusableModel && reusableFile
? appendReusableFileRows({
rows,
input,
model: reusableModel,
file: reusableFile,
fileIndex,
fileTop,
bodyTop,
reviewGeometryKey: currentReviewGeometryKey,
})
: appendNewFileRows({
rows,
input,
file,
fileIndex,
fileTop,
bodyTop,
gutterWidth,
});
maximumHorizontalOverflow = Math.max(
maximumHorizontalOverflow,
gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
);
return cell;
}) as DiffLineRow["cells"];
const textHeight = Math.max(
input.typography.lineHeight,
...cells.map((cell) => (cell?.fragments.length ?? 1) * input.typography.lineHeight),
);
const reviewHeight = reviewHeightForCells(cells, input);
const height = textHeight + reviewHeight;
rows.push({
kind: "line",
index: rows.length,
fileIndex,
path: file.path,
top: documentTop,
height,
cells,
reviewHeight,
});
documentTop += height;
}
}
documentTop += DIFF_BODY_BORDER_HEIGHT;
documentTop = result.bottom;
maximumHorizontalOverflow = result.maximumHorizontalOverflow;
}
files.push({
@@ -170,9 +138,193 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
layout: input.layout,
wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth,
reviewGeometryKey: currentReviewGeometryKey,
};
}
function appendReusableFileRows(candidate: {
rows: DiffRow[];
input: BuildDiffDocumentModelInput;
model: DiffDocumentModel;
file: DiffFileSection;
fileIndex: number;
fileTop: number;
bodyTop: number;
reviewGeometryKey: string;
}): 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.rowStart === candidate.rows.length;
if (retainsRowGeometry) {
candidate.rows.push(...reusedRows);
return {
bottom: candidate.bodyTop + candidate.file.bodyHeight,
maximumHorizontalOverflow: Math.max(
0,
candidate.file.contentWidth - candidate.input.viewportWidth,
),
};
}
let top = candidate.bodyTop;
for (const reusedRow of reusedRows) {
const nextRow = repositionReusedRow(reusedRow, candidate, top);
candidate.rows.push(nextRow);
top += nextRow.height;
}
return {
bottom: top + DIFF_BODY_BORDER_HEIGHT,
maximumHorizontalOverflow: Math.max(
0,
candidate.file.contentWidth - candidate.input.viewportWidth,
),
};
}
function repositionReusedRow(
row: DiffRow,
candidate: Pick<Parameters<typeof appendReusableFileRows>[0], "rows" | "input" | "fileIndex">,
top: number,
): DiffRow {
if (row.kind !== "line") {
return { ...row, index: candidate.rows.length, fileIndex: candidate.fileIndex, top };
}
const reviewHeight = reviewHeightForCells(row.cells, candidate.input);
return {
...row,
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
top,
height: row.height - row.reviewHeight + reviewHeight,
reviewHeight,
};
}
function appendNewFileRows(candidate: {
rows: DiffRow[];
input: BuildDiffDocumentModelInput;
file: BuildDiffDocumentModelInput["files"][number];
fileIndex: number;
fileTop: number;
bodyTop: number;
gutterWidth: number;
}): FileRowsResult {
if (candidate.file.status === "binary" || candidate.file.status === "too_large") {
const height = candidate.input.typography.lineHeight + 24;
candidate.rows.push({
kind: "status",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top: candidate.bodyTop,
height,
label:
candidate.file.status === "binary"
? candidate.input.labels.binary
: candidate.input.labels.tooLarge,
});
return {
bottom: candidate.bodyTop + height + DIFF_BODY_BORDER_HEIGHT,
maximumHorizontalOverflow: 0,
};
}
const deferredLines =
candidate.input.materializationWindow && !candidate.input.wrapLines
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
: null;
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
const shouldMaterialize =
deferredLines === null ||
intersectsMaterializationWindow(
candidate.input,
candidate.fileTop,
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
);
return shouldMaterialize
? appendMeasuredFileRows(candidate)
: appendGeometryFileRows(candidate, deferredLines);
}
function appendGeometryFileRows(
candidate: Parameters<typeof appendNewFileRows>[0],
lines: readonly GeometryLine[],
): FileRowsResult {
let top = candidate.bodyTop;
for (const line of lines) {
candidate.rows.push({
kind: "line",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top,
height: line.height,
cells: line.cells,
reviewHeight: line.reviewHeight,
});
top += line.height;
}
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
}
function appendMeasuredFileRows(
candidate: Parameters<typeof appendNewFileRows>[0],
): FileRowsResult {
let top = candidate.bodyTop;
let maximumHorizontalOverflow = 0;
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
const columnCount = sourceCells.length;
const columnWidth = candidate.input.viewportWidth / columnCount;
const availableWidth = Math.max(
candidate.input.measureText.measure("M"),
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
);
const cells = sourceCells.map((source) => {
if (!source) return null;
const cell = measureCell({ source, availableWidth, input: candidate.input });
maximumHorizontalOverflow = Math.max(
maximumHorizontalOverflow,
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
);
return cell;
}) as DiffLineRow["cells"];
const textHeight = Math.max(
candidate.input.typography.lineHeight,
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
);
const reviewHeight = reviewHeightForCells(cells, candidate.input);
const height = textHeight + reviewHeight;
candidate.rows.push({
kind: "line",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top,
height,
cells,
reviewHeight,
});
top += height;
}
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
}
export function reviewGeometryKey(
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
): string {
if (!reviewActions) return "";
const comments = [...reviewActions.commentsByTarget.entries()]
.map(([target, targetComments]) => [target, targetComments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right)));
const editor = reviewActions.editor
? [reviewActions.editor.target.key, reviewActions.editor.commentId]
: null;
return JSON.stringify([comments, editor]);
}
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
"worklet";
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
@@ -183,7 +335,7 @@ export function retainReusableModels(
next: DiffDocumentModel,
): DiffDocumentModel[] {
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
candidate.rows.length > best.rows.length ? candidate : best,
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
);
return fullest === next ? [next] : [fullest, next];
}
@@ -191,13 +343,14 @@ export function retainReusableModels(
function lineSources(
file: BuildDiffDocumentModelInput["files"][number],
layout: "unified" | "split",
includeTokens: boolean,
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
if (layout === "split") {
return buildSplitDiffRows(file).map((row) => {
if (row.kind === "header") {
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
}
return [cellSource(row.left), cellSource(row.right)];
return [cellSource(row.left, includeTokens), cellSource(row.right, includeTokens)];
});
}
return buildUnifiedDiffLines(file).map((entry) => [
@@ -206,7 +359,7 @@ function lineSources(
content: entry.line.content,
lineNumber: entry.lineNumber,
reviewTarget: entry.reviewTarget,
tokenText: compactHighlightTokens(entry.line.tokens ?? []),
tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
sourceIdentity: {
hunkIndex: entry.hunkIndex,
lineIndex: entry.lineIndex,
@@ -227,14 +380,14 @@ function headerSource(content: string, hunkIndex: number, lineIndex: number): Ce
};
}
function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): CellSource | null {
if (!line) return null;
return {
type: line.type,
content: line.content,
lineNumber: line.lineNumber,
reviewTarget: line.reviewTarget,
tokenText: compactHighlightTokens(line.tokens ?? []),
tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
sourceIdentity: {
hunkIndex: line.hunkIndex,
lineIndex: line.lineIndex,
@@ -243,6 +396,112 @@ function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
};
}
function geometryCell(source: CellSource): DiffCell {
return {
type: source.type,
content: source.content,
lineNumber: source.lineNumber,
tokens: [],
fragments: [],
reviewTarget: source.reviewTarget,
sourceIdentity: source.sourceIdentity,
};
}
function geometryLines(
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
input: BuildDiffDocumentModelInput,
): GeometryLine[] {
return sources.map((sourceCells) => {
const cells = sourceCells.map((source) =>
source ? geometryCell(source) : null,
) as DiffLineRow["cells"];
const reviewHeight = reviewHeightForCells(cells, input);
return {
cells,
reviewHeight,
height: input.typography.lineHeight + reviewHeight,
};
});
}
function intersectsMaterializationWindow(
input: BuildDiffDocumentModelInput,
fileTop: number,
fileBottom: number,
): boolean {
const window = input.materializationWindow;
if (!window) return true;
return fileTop < window.top + window.height && fileBottom > window.top;
}
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
if (file.isCollapsed) return false;
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
if (row.kind !== "line") return true;
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
});
}
function indexReusableModels(
models: readonly DiffDocumentModel[] | undefined,
): ReusableModelIndex[] {
return (models ?? []).map((model) => {
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
const measuredFiles = new Set<DiffFileSection>();
for (const file of model.files) {
filesBySource.set(file.file, file);
if (hasMeasuredFileRows(model, file)) measuredFiles.add(file);
}
return { model, filesBySource, measuredFiles };
});
}
function findReusableFileRows(candidate: {
indexes: readonly ReusableModelIndex[];
buildInput: BuildDiffDocumentModelInput;
sourceFile: BuildDiffDocumentModelInput["files"][number];
fileTop: number;
bodyTop: number;
}): ReusableFileRows | null {
for (const index of candidate.indexes) {
const file = index.filesBySource.get(candidate.sourceFile);
if (
file &&
canReuseFileRows({
buildInput: candidate.buildInput,
file,
isMeasured: index.measuredFiles.has(file),
fileTop: candidate.fileTop,
bodyTop: candidate.bodyTop,
})
) {
return { model: index.model, file };
}
}
return null;
}
function canReuseFileRows(candidate: {
buildInput: BuildDiffDocumentModelInput;
file: DiffFileSection;
isMeasured: boolean;
fileTop: number;
bodyTop: number;
}): boolean {
if (candidate.file.isCollapsed) return false;
if (candidate.isMeasured) return true;
return !intersectsMaterializationWindow(
candidate.buildInput,
candidate.fileTop,
candidate.bodyTop + candidate.file.bodyHeight,
);
}
function measuredFileCount(model: DiffDocumentModel): number {
return model.files.filter((file) => hasMeasuredFileRows(model, file)).length;
}
function measureCell(input: {
source: CellSource;
availableWidth: number;
@@ -107,6 +107,11 @@ function build(layout: "unified" | "split") {
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -5,7 +5,6 @@ import {
nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket,
nativeCanvasWindowTop,
nativeStickyHeaderIndices,
} from "./native-slabs";
import type { DiffDocumentModel } from "./types";
@@ -34,15 +33,11 @@ function modelWithBodies(
layout: "unified",
wrapLines: false,
viewportWidth: 400,
reviewGeometryKey: "",
};
}
describe("native canvas slabs", () => {
it("identifies each direct file header child for native sticky scrolling", () => {
expect(nativeStickyHeaderIndices(0)).toEqual([]);
expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]);
});
it("cuts large file bodies into fixed document-positioned slabs", () => {
const slabs = buildNativeCanvasSlabs(
modelWithBodies([
@@ -58,9 +53,11 @@ describe("native canvas slabs", () => {
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
{ path: "small.ts", top: 30, height: 200 },
{ path: "large.ts", top: 260, height: 1200 },
{ path: "large.ts", top: 1460, height: 1200 },
{ path: "large.ts", top: 2660, height: 800 },
{ path: "large.ts", top: 260, height: 768 },
{ path: "large.ts", top: 1028, height: 768 },
{ path: "large.ts", top: 1796, height: 768 },
{ path: "large.ts", top: 2564, height: 768 },
{ path: "large.ts", top: 3332, height: 128 },
]);
});
@@ -73,7 +70,7 @@ describe("native canvas slabs", () => {
);
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
30, 1230, 2430,
798, 1566, 2334,
]);
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
@@ -8,15 +8,11 @@ export interface NativeCanvasSlab {
height: number;
}
export function nativeStickyHeaderIndices(fileCount: number): number[] {
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
}
export function buildNativeCanvasSlabs(
model: DiffDocumentModel,
viewportHeight: number,
): NativeCanvasSlab[] {
const slabHeight = Math.max(768, viewportHeight * 2);
const slabHeight = Math.max(768, viewportHeight);
return model.files.flatMap((file) => {
const slabs: NativeCanvasSlab[] = [];
let slabIndex = 0;
@@ -39,8 +35,8 @@ export function nativeCanvasSlabsForViewport(
scrollTop: number,
viewportHeight: number,
): NativeCanvasSlab[] {
const top = Math.max(0, scrollTop - viewportHeight);
const bottom = scrollTop + viewportHeight * 2;
const top = Math.max(0, scrollTop - viewportHeight / 2);
const bottom = scrollTop + viewportHeight * 1.5;
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
}
@@ -1,16 +1,37 @@
import {
ClipOp,
PaintStyle,
Skia,
StrokeCap,
StrokeJoin,
type SkCanvas,
type SkPaint,
type SkPicture,
} from "@shopify/react-native-skia";
import {
DIFF_FILE_HEADER_CONTENT_HEIGHT,
DIFF_FILE_HEADER_HEIGHT,
DIFF_FILE_HEADER_ICON_SIZE,
DIFF_FILE_HEADER_LEFT,
DIFF_FILE_HEADER_RIGHT,
DIFF_FILE_HEADER_TEXT_GAP,
allocateDiffHeaderTextWidths,
diffFileChangeKind,
directorySuffix,
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
import { nativeTextRuns } from "./native-text-runs";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import { codeLineNumberTone } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type { NativeTextLayout } from "./text.native";
import {
shapeNativeHeaderText,
type NativeHeaderTextLayout,
type NativeShapedHeaderText,
type NativeTextLayout,
} from "./text.native";
import type {
DiffCell,
DiffDocumentModel,
@@ -31,6 +52,10 @@ export interface NativePaints {
additionBackground: SkPaint;
deletionBackground: SkPaint;
headerSurface: SkPaint;
headerBorder: SkPaint;
statusSuccess: SkPaint;
statusDanger: SkPaint;
statusWarning: SkPaint;
emptyBackground: SkPaint;
text: Record<string, SkPaint>;
}
@@ -46,6 +71,10 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
additionBackground: paint(palette.additionBackground),
deletionBackground: paint(palette.deletionBackground),
headerSurface: paint(palette.headerSurface),
headerBorder: paint(palette.headerBorder),
statusSuccess: paint(palette.statusSuccess),
statusDanger: paint(palette.statusDanger),
statusWarning: paint(palette.statusWarning),
emptyBackground: paint(palette.emptyBackground),
text: Object.fromEntries(
[
@@ -55,6 +84,156 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
};
}
export function recordNativeHeaderPicture(input: {
file: DiffFileSection;
viewportWidth: number;
textLayout: NativeHeaderTextLayout;
paints: NativePaints;
}): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, DIFF_FILE_HEADER_HEIGHT),
);
canvas.drawRect(
Skia.XYWHRect(0, DIFF_FILE_HEADER_HEIGHT - 1, input.viewportWidth, 1),
input.paints.headerBorder,
);
const iconX = input.viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
const additions = `+${formatDiffCount(input.file.file.additions)}`;
const deletions = `-${formatDiffCount(input.file.file.deletions)}`;
const additionsText = shapeNativeHeaderText({
layout: input.textLayout,
text: additions,
size: "stat",
tone: "statusSuccess",
});
const deletionsText = shapeNativeHeaderText({
layout: input.textLayout,
text: deletions,
size: "stat",
tone: "statusDanger",
});
const statX = iconX - 8 - additionsText.width - 4 - deletionsText.width;
paintNativeHeaderText(canvas, additionsText, statX);
paintNativeHeaderText(canvas, deletionsText, statX + additionsText.width + 4);
paintNativeChangeIcon(
canvas,
input.file,
iconX,
(DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
input.paints,
);
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
const name = fileNameForPath(input.file.path);
const directory = directorySuffix(input.file.path).trimStart();
const fitted = fitNativeHeaderText(input.textLayout, name, directory, available);
canvas.save();
canvas.clipRect(
Skia.XYWHRect(DIFF_FILE_HEADER_LEFT, 0, available, DIFF_FILE_HEADER_CONTENT_HEIGHT),
ClipOp.Intersect,
false,
);
paintNativeHeaderText(canvas, fitted.name, DIFF_FILE_HEADER_LEFT);
if (fitted.directory.width > 0) {
paintNativeHeaderText(
canvas,
fitted.directory,
DIFF_FILE_HEADER_LEFT + fitted.name.width + DIFF_FILE_HEADER_TEXT_GAP,
);
}
canvas.restore();
const picture = recorder.finishRecordingAsPicture();
additionsText.paragraph.dispose();
deletionsText.paragraph.dispose();
fitted.name.paragraph.dispose();
fitted.directory.paragraph.dispose();
return picture;
}
function paintNativeHeaderText(canvas: SkCanvas, text: NativeShapedHeaderText, x: number): void {
text.paragraph.paint(canvas, x, (DIFF_FILE_HEADER_CONTENT_HEIGHT - text.height) / 2);
}
function fitNativeHeaderText(
layout: NativeHeaderTextLayout,
name: string,
directory: string,
available: number,
): { name: NativeShapedHeaderText; directory: NativeShapedHeaderText } {
let nameText = shapeNativeHeaderText({ layout, text: name, size: "body", tone: "foreground" });
let directoryText = shapeNativeHeaderText({
layout,
text: directory,
size: "body",
tone: "foregroundMuted",
});
if (nameText.width + (directory ? 4 + directoryText.width : 0) <= available) {
return { name: nameText, directory: directoryText };
}
const widths = allocateDiffHeaderTextWidths({
available,
nameWidth: nameText.width,
directoryWidth: directoryText.width,
});
if (widths.name < nameText.width) {
nameText.paragraph.dispose();
nameText = shapeNativeHeaderText({
layout,
text: name,
size: "body",
tone: "foreground",
maximumWidth: widths.name,
});
}
directoryText.paragraph.dispose();
directoryText = shapeNativeHeaderText({
layout,
text: widths.directory > 0 ? directory : "",
size: "body",
tone: "foregroundMuted",
maximumWidth: widths.directory,
});
return { name: nameText, directory: directoryText };
}
function paintNativeChangeIcon(
canvas: SkCanvas,
file: DiffFileSection,
x: number,
y: number,
paints: NativePaints,
): void {
const change = diffFileChangeKind(file.file);
let source = paints.statusWarning;
if (change === "added") source = paints.statusSuccess;
else if (change === "deleted") source = paints.statusDanger;
const stroke = source.copy();
stroke.setStyle(PaintStyle.Stroke);
stroke.setStrokeWidth(DIFF_FILE_HEADER_ICON_SIZE / 12);
stroke.setStrokeCap(StrokeCap.Round);
stroke.setStrokeJoin(StrokeJoin.Round);
const scale = DIFF_FILE_HEADER_ICON_SIZE / 24;
canvas.drawRRect(
Skia.RRectXY(
Skia.XYWHRect(x + 3 * scale, y + 3 * scale, 18 * scale, 18 * scale),
2 * scale,
2 * scale,
),
stroke,
);
if (change === "added") {
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
canvas.drawLine(x + 12 * scale, y + 8 * scale, x + 12 * scale, y + 16 * scale, stroke);
} else if (change === "deleted") {
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
} else {
canvas.drawCircle(x + 12 * scale, y + 12 * scale, scale, stroke);
}
stroke.dispose();
}
function paint(color: string): SkPaint {
const result = Skia.Paint();
result.setColor(Skia.Color(color));
@@ -1,8 +1,94 @@
import { describe, expect, it } from "vitest";
import { paintWebViewport } from "./paint.web";
import { paintWebHeaders, paintWebViewport } from "./paint.web";
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
describe("web diff text shaping", () => {
it("matches the 30px file-header alignment rails exactly", () => {
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
const labels: Array<{ text: string; x: number; y: number; color: string; font: string }> = [];
let fillStyle = "";
let font = "";
const context = {
setTransform() {},
clearRect() {},
fillRect(x: number, y: number, width: number, height: number) {
fills.push({ color: fillStyle, x, y, width, height });
},
save() {},
restore() {},
beginPath() {},
rect() {},
clip() {},
translate() {},
scale() {},
roundRect() {},
moveTo() {},
lineTo() {},
stroke() {},
measureText(text: string) {
return {
width: text.length * 6,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
fillText(text: string, x: number, y: number) {
labels.push({ text, x, y, color: fillStyle, font });
},
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = String(value);
},
get font() {
return font;
},
set font(value: string) {
font = value;
},
strokeStyle: "",
lineWidth: 1,
lineCap: "butt",
lineJoin: "miter",
} as unknown as CanvasRenderingContext2D;
paintWebHeaders({
context,
model: {
...model,
files: [
{
...model.files[0]!,
headerHeight: 30,
bodyTop: 30,
bodyHeight: 18,
bottom: 48,
},
],
height: 48,
},
palette,
typography: { family: "system-ui", size: 14, statSize: 12 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
devicePixelRatio: 1,
activePath: null,
});
expect(fills.slice(0, 2)).toEqual([
{ color: "header", x: 0, y: 0, width: 200, height: 30 },
{ color: "header-border", x: 0, y: 29, width: 200, height: 1 },
]);
expect(labels).toEqual([
{ text: "+1", x: 142, y: 18, color: "success", font: "12px system-ui" },
{ text: "-0", x: 158, y: 18, color: "danger", font: "12px system-ui" },
{ text: "a.ts", x: 12, y: 18, color: "foreground", font: "14px system-ui" },
{ text: "src", x: 40, y: 18, color: "muted", font: "14px system-ui" },
]);
});
it("paints syntax colors by clipping the complete measured shaped fragment", () => {
const paintedText: string[] = [];
const context = {
@@ -314,6 +400,7 @@ function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
layout,
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
}
@@ -356,6 +443,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg",
emptyBackground: "empty",
selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: { first: "red", second: "blue" },
};
@@ -427,6 +519,7 @@ const model: DiffDocumentModel = {
layout: "unified",
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
const modelWithReview: DiffDocumentModel = {
@@ -6,10 +6,26 @@ import {
visibleRowRange,
} from "./model";
import { codeLineNumberTone, codeTextColor } from "./palette";
import {
DIFF_FILE_HEADER_CONTENT_HEIGHT,
DIFF_FILE_HEADER_HEIGHT,
DIFF_FILE_HEADER_ICON_SIZE,
DIFF_FILE_HEADER_LEFT,
DIFF_FILE_HEADER_RIGHT,
DIFF_FILE_HEADER_TEXT_GAP,
allocateDiffHeaderTextWidths,
diffFileChangeKind,
directorySuffix,
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import { resolveVisibleFileSections } from "./header-layout";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type {
DiffCell,
DiffDocumentModel,
DiffFileSection,
DiffHeaderTypography,
DiffLineRow,
DiffPalette,
DiffSelection,
@@ -86,6 +102,167 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
context.restore();
}
export function paintWebHeaders(input: {
context: CanvasRenderingContext2D;
model: DiffDocumentModel;
palette: DiffPalette;
typography: DiffHeaderTypography;
scrollTop: number;
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: number;
activePath: string | null;
}): void {
const scale = input.devicePixelRatio;
input.context.setTransform(scale, 0, 0, scale, 0, 0);
input.context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
const window = resolveVisibleFileSections({
files: input.model.files,
scrollTop: input.scrollTop,
viewportHeight: input.viewportHeight,
overscan: 0,
});
for (const file of window.files) {
if (file === window.sticky?.file) continue;
const y = file.top - input.scrollTop;
if (y + file.headerHeight <= 0 || y >= input.viewportHeight) continue;
paintWebHeader({ ...input, file, y });
}
if (window.sticky) paintWebHeader({ ...input, file: window.sticky.file, y: window.sticky.y });
}
function paintWebHeader(
input: Parameters<typeof paintWebHeaders>[0] & { file: DiffFileSection; y: number },
): void {
const { context, file, palette, typography, viewportWidth, y } = input;
context.fillStyle =
input.activePath === file.path ? palette.headerActiveSurface : palette.headerSurface;
context.fillRect(0, y, viewportWidth, DIFF_FILE_HEADER_HEIGHT);
context.fillStyle = palette.headerBorder;
context.fillRect(0, y + DIFF_FILE_HEADER_HEIGHT - 1, viewportWidth, 1);
const iconX = viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
const statLabels = [
`+${formatDiffCount(file.file.additions)}`,
`-${formatDiffCount(file.file.deletions)}`,
];
context.font = `${typography.statSize}px ${typography.family}`;
const statWidths = statLabels.map((label) => context.measureText(label).width);
const statWidth = statWidths[0]! + 4 + statWidths[1]!;
const statX = iconX - 8 - statWidth;
const statBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
context.fillStyle = palette.statusSuccess;
context.fillText(statLabels[0]!, statX, statBaseline);
context.fillStyle = palette.statusDanger;
context.fillText(statLabels[1]!, statX + statWidths[0]! + 4, statBaseline);
paintChangeIcon(
context,
file,
iconX,
y + (DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
palette,
);
context.font = `${typography.size}px ${typography.family}`;
const textBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
const name = fileNameForPath(file.path);
// React Native Web collapses the leading separator space at the start of the
// directory Text node. Canvas does not, so paint only the visible glyphs.
const directory = directorySuffix(file.path).trimStart();
const fitted = fitHeaderText(context, name, directory, available);
context.save();
context.beginPath();
context.rect(DIFF_FILE_HEADER_LEFT, y, available, DIFF_FILE_HEADER_CONTENT_HEIGHT);
context.clip();
context.fillStyle = palette.foreground;
context.fillText(fitted.name, DIFF_FILE_HEADER_LEFT, textBaseline);
if (fitted.directory) {
context.fillStyle = palette.foregroundMuted;
context.fillText(
fitted.directory,
DIFF_FILE_HEADER_LEFT + context.measureText(fitted.name).width + DIFF_FILE_HEADER_TEXT_GAP,
textBaseline,
);
}
context.restore();
}
function centeredTextBaseline(context: CanvasRenderingContext2D, centerY: number): number {
const metrics = context.measureText("Mg");
return centerY + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2 + 1;
}
function fitHeaderText(
context: CanvasRenderingContext2D,
name: string,
directory: string,
available: number,
): { name: string; directory: string } {
const nameWidth = context.measureText(name).width;
const directoryWidth = context.measureText(directory).width;
if (nameWidth + (directory ? 4 + directoryWidth : 0) <= available) return { name, directory };
const widths = allocateDiffHeaderTextWidths({ available, nameWidth, directoryWidth });
return {
name: truncateCanvasText(context, name, widths.name),
directory: truncateCanvasText(context, directory, widths.directory),
};
}
function truncateCanvasText(
context: CanvasRenderingContext2D,
text: string,
maximumWidth: number,
): string {
if (context.measureText(text).width <= maximumWidth) return text;
const ellipsis = "…";
if (context.measureText(ellipsis).width > maximumWidth) return "";
let low = 0;
let high = text.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (context.measureText(`${text.slice(0, middle)}${ellipsis}`).width <= maximumWidth)
low = middle;
else high = middle - 1;
}
return `${text.slice(0, low)}${ellipsis}`;
}
function paintChangeIcon(
context: CanvasRenderingContext2D,
file: DiffFileSection,
x: number,
y: number,
palette: DiffPalette,
): void {
const change = diffFileChangeKind(file.file);
context.save();
context.translate(x, y);
context.scale(DIFF_FILE_HEADER_ICON_SIZE / 24, DIFF_FILE_HEADER_ICON_SIZE / 24);
if (change === "added") context.strokeStyle = palette.statusSuccess;
else if (change === "deleted") context.strokeStyle = palette.statusDanger;
else context.strokeStyle = palette.statusWarning;
context.lineWidth = 2;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.roundRect(3, 3, 18, 18, 2);
if (change === "added") {
context.moveTo(8, 12);
context.lineTo(16, 12);
context.moveTo(12, 8);
context.lineTo(12, 16);
} else if (change === "deleted") {
context.moveTo(8, 12);
context.lineTo(16, 12);
} else {
context.moveTo(12, 12);
context.lineTo(12.01, 12);
}
context.stroke();
context.restore();
}
function paintLine(
input: PaintWebViewportInput & {
row: DiffLineRow;
@@ -59,6 +59,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg",
emptyBackground: "empty",
selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: {},
};
@@ -15,6 +15,11 @@ export function createDiffPalette(theme: Theme): DiffPalette {
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
emptyBackground: theme.colors.surface0,
selection: theme.colors.terminal.blue,
headerActiveSurface: theme.colors.surface1,
headerBorder: theme.colors.borderAccent,
statusSuccess: theme.colors.statusSuccess,
statusDanger: theme.colors.statusDanger,
statusWarning: theme.colors.statusWarning,
syntax: theme.colors.syntax,
};
}
@@ -32,6 +37,11 @@ export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): Dif
previous.deletionBackground !== next.deletionBackground ||
previous.emptyBackground !== next.emptyBackground ||
previous.selection !== next.selection ||
previous.headerActiveSurface !== next.headerActiveSurface ||
previous.headerBorder !== next.headerBorder ||
previous.statusSuccess !== next.statusSuccess ||
previous.statusDanger !== next.statusDanger ||
previous.statusWarning !== next.statusWarning ||
!sameColorMap(previous.syntax, next.syntax)
) {
return next;
@@ -23,6 +23,11 @@ import { InlineReviewThread } from "@/review";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DocumentFileHeader } from "./document-file-header";
import {
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffBodyPoint } from "./native-hit-testing";
import { retainDiffViewport } from "./viewport";
import { HorizontalScroll } from "./horizontal-scroll.native";
@@ -42,16 +47,22 @@ import {
nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket,
nativeCanvasWindowTop,
nativeStickyHeaderIndices,
type NativeCanvasSlab,
} from "./native-slabs";
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native";
import {
createNativePaints,
recordNativeHeaderPicture,
recordNativeSlabPictures,
type NativePaints,
} from "./paint.native";
import {
createNativeTextLayoutStore,
createNativeHeaderTextLayout,
createNativeTextMeasurer,
disposeNativeTextLayout,
prepareNativeTextLayout,
type NativeTextLayout,
type NativeHeaderTextLayout,
} from "./text.native";
import type {
DiffDocumentModel,
@@ -68,6 +79,7 @@ const CODE_LEFT_PADDING = 8;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [fileWindowTop, setFileWindowTop] = useState(0);
const scrollRef = useRef<ScrollView>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
@@ -126,6 +138,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
reuseFrom,
});
reusableModelRef.current = {
@@ -135,6 +148,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
return next;
}, [
measurement,
fileWindowTop,
props.collapsedFilePaths,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
@@ -144,6 +158,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
t,
typography,
viewport.width,
viewport.height,
]);
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
const textLayoutStore = useMemo(
@@ -161,6 +176,38 @@ export function DiffSurface(props: DiffSurfaceProps) {
[model, textLayoutStore],
);
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
const headerTextLayout = useMemo(
() =>
createNativeHeaderTextLayout({
configuredFamily: props.headerTypography.family,
fontSize: props.headerTypography.size,
statFontSize: props.headerTypography.statSize,
palette: props.palette,
}),
[props.headerTypography, props.palette],
);
const updateFileWindow = useCallback((nextTop: number) => {
setFileWindowTop((current) => (current === nextTop ? current : nextTop));
}, []);
useAnimatedReaction(
() => diffInteractionWindowTop(scrollTop.value, viewport.height),
(windowTop, previous) => {
if (windowTop !== previous) {
scheduleOnRN(updateFileWindow, windowTop);
}
},
[updateFileWindow, viewport.height],
);
const interactionFiles = useMemo(
() =>
resolveVisibleFileSections({
files: model.files,
scrollTop: fileWindowTop,
viewportHeight: viewport.height,
overscan: viewport.height * 2,
}).files,
[fileWindowTop, model.files, viewport.height],
);
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollTop.value = event.contentOffset.y;
@@ -206,12 +253,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
}
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentStyle = useMemo(
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
[viewport.height],
);
const stickyHeaderIndices = useMemo(
() => nativeStickyHeaderIndices(model.files.length),
[model.files.length],
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
[model.height, viewport.height],
);
return (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
@@ -222,7 +265,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll"
>
<NativeCanvasSlabLayer
@@ -233,23 +275,31 @@ export function DiffSurface(props: DiffSurfaceProps) {
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
{model.files.flatMap((file) => [
<NativeStickyFileHeader
{interactionFiles.map((file) => (
<NativeCanvasFileHeader
key={`${file.path}:header`}
file={file}
viewportWidth={model.viewportWidth}
scrollTop={scrollTop}
textLayout={headerTextLayout}
paints={paints}
headerSurface={props.palette.headerSurface}
headerActiveSurface={props.palette.headerActiveSurface}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>,
/>
))}
{interactionFiles.map((file) => (
<NativeFileBody
key={`${file.path}:body`}
file={file}
model={model}
mode={props.mode}
horizontalOffsets={horizontalOffsets}
/>,
])}
/>
))}
<NativeReviewOverlays model={model} mode={props.mode} />
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
</AnimatedScrollView>
@@ -306,29 +356,69 @@ function NativeCanvasSlabLayer({
));
}
function NativeStickyFileHeader({
function NativeCanvasFileHeader({
file,
viewportWidth,
scrollTop,
textLayout,
paints,
headerSurface,
headerActiveSurface,
selectedPath,
mode,
onToggleFile,
onSelectPath,
}: {
file: DiffFileSection;
viewportWidth: number;
scrollTop: SharedValue<number>;
textLayout: NativeHeaderTextLayout;
paints: NativePaints;
headerSurface: string;
headerActiveSurface: string;
selectedPath: DiffSurfaceProps["selectedPath"];
mode: DiffSurfaceProps["mode"];
onToggleFile: DiffSurfaceProps["onToggleFile"];
onSelectPath: DiffSurfaceProps["onSelectPath"];
}) {
const [active, setActive] = useState(false);
const picture = useMemo(
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
[file, paints, textLayout, viewportWidth],
);
const stickyStyle = useAnimatedStyle(() => {
const pinOffset = Math.max(0, scrollTop.value - file.top);
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
const style = useMemo<ViewStyle>(
() => ({
position: "absolute",
top: file.top,
left: 0,
width: viewportWidth,
height: file.headerHeight,
backgroundColor: active ? headerActiveSurface : headerSurface,
zIndex: 5,
elevation: 2,
}),
[active, file.headerHeight, file.top, headerActiveSurface, headerSurface, viewportWidth],
);
return (
<View style={styles.header}>
<Animated.View style={[style, stickyStyle]}>
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Picture picture={picture} />
</Canvas>
<DocumentFileHeader
file={file}
selectedPath={selectedPath}
mode={mode}
onToggleFile={onToggleFile}
onSelectPath={onSelectPath}
canvasRendered
onActiveChange={setActive}
/>
</View>
</Animated.View>
);
}
@@ -382,7 +472,10 @@ function NativeFileBody({
<View
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
position: "absolute",
top: file.bodyTop,
left: 0,
right: 0,
height: file.bodyHeight,
zIndex: 2,
})}
@@ -579,5 +672,4 @@ function NativeSlabCode({
const styles = StyleSheet.create({
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
scroll: { backgroundColor: "transparent" },
header: { zIndex: 5 },
});
@@ -5,11 +5,17 @@ import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overl
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import {
diffHeaderViewportKey,
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
import { paintWebViewport } from "./paint.web";
import { paintWebHeaders, paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import { createMeasuredAdvances } from "./text-measurement";
import { retainDiffViewport } from "./viewport";
@@ -30,6 +36,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
@@ -46,6 +53,17 @@ export function DiffSurface(props: DiffSurfaceProps) {
dismissSelectionOnClick: boolean;
} | null>(null);
const frameRef = useRef<number | null>(null);
const headerFrameRef = useRef<number | null>(null);
const headerPaintRef = useRef<{
key: string;
model: ReturnType<typeof buildDiffDocumentModel>;
width: number;
height: number;
ratio: number;
palette: DiffSurfaceProps["palette"];
typography: DiffSurfaceProps["headerTypography"];
} | null>(null);
const activeHeaderPathRef = useRef<string | null>(null);
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeReleaseFrameRef = useRef<number | null>(null);
const resizePointerActiveRef = useRef(false);
@@ -53,6 +71,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [fileWindowTop, setFileWindowTop] = useState(0);
const [interactionFiles, setInteractionFiles] = useState<
ReturnType<typeof buildDiffDocumentModel>["files"]
>([]);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -81,12 +103,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(
`400 ${props.headerTypography.size}px ${props.headerTypography.family}`,
),
document.fonts.load(
`400 ${props.headerTypography.statSize}px ${props.headerTypography.family}`,
),
]);
await document.fonts.ready;
},
createMeasurer: () => createWebTextMeasurer(desiredTypography),
}),
[desiredTypography, workspaceCache],
[desiredTypography, props.headerTypography, workspaceCache],
);
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
typeof workspaceCache.typography
@@ -105,7 +133,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight,
});
}
return workspaceCache.buildModel({
const next = workspaceCache.buildModel({
files: props.files,
collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout,
@@ -119,8 +147,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
});
return next;
}, [
fileWindowTop,
measurement,
props.collapsedFilePaths,
props.displayPreferences.layout,
@@ -132,6 +163,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
desiredTypography.lineHeight,
loadedTypography,
viewport.width,
viewport.height,
workspaceCache,
]);
modelRef.current = model;
@@ -213,6 +245,64 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[paint],
);
const paintHeaders = useCallback(() => {
headerFrameRef.current = null;
const canvas = headerCanvasRef.current;
const currentModel = modelRef.current;
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
const ratio = window.devicePixelRatio || 1;
const key = diffHeaderViewportKey({
files: currentModel.files,
scrollTop: scrollTopRef.current,
viewportHeight: viewport.height,
activePath: activeHeaderPathRef.current,
});
const previous = headerPaintRef.current;
if (
previous?.key === key &&
previous.model === currentModel &&
previous.width === viewport.width &&
previous.height === viewport.height &&
previous.ratio === ratio &&
previous.palette === props.palette &&
previous.typography === props.headerTypography
) {
return;
}
headerPaintRef.current = {
key,
model: currentModel,
width: viewport.width,
height: viewport.height,
ratio,
palette: props.palette,
typography: props.headerTypography,
};
const pixelWidth = Math.ceil(viewport.width * ratio);
const pixelHeight = Math.ceil(viewport.height * ratio);
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
const context = canvas.getContext("2d");
if (!context) return;
paintWebHeaders({
context,
model: currentModel,
palette: props.palette,
typography: props.headerTypography,
scrollTop: scrollTopRef.current,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
devicePixelRatio: ratio,
activePath: activeHeaderPathRef.current,
});
}, [props.headerTypography, props.palette, viewport.height, viewport.width]);
const scheduleHeaderPaint = useCallback(() => {
if (headerFrameRef.current === null) {
headerFrameRef.current = requestAnimationFrame(paintHeaders);
}
}, [paintHeaders]);
useLayoutEffect(() => {
const root = rootRef.current;
@@ -300,13 +390,37 @@ export function DiffSurface(props: DiffSurfaceProps) {
props.files.map((file) => file.path),
);
}, [props.files]);
useLayoutEffect(schedulePaint, [model, schedulePaint]);
const updateInteractionFiles = useCallback(
(scrollTop: number) => {
const windowTop = diffInteractionWindowTop(scrollTop, viewport.height);
setFileWindowTop((current) => (current === windowTop ? current : windowTop));
const next = resolveVisibleFileSections({
files: model.files,
scrollTop: windowTop,
viewportHeight: viewport.height,
overscan: viewport.height * 2,
}).files;
setInteractionFiles((current) =>
current.length === next.length && current[0] === next[0] && current.at(-1) === next.at(-1)
? current
: next,
);
},
[model.files, viewport.height],
);
useLayoutEffect(() => {
schedulePaint();
scheduleHeaderPaint();
updateInteractionFiles(scrollTopRef.current);
}, [model, scheduleHeaderPaint, schedulePaint, updateInteractionFiles]);
useEffect(
() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (headerFrameRef.current !== null) cancelAnimationFrame(headerFrameRef.current);
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
// request so the refreshed renderer can schedule its first paint.
frameRef.current = null;
headerFrameRef.current = null;
},
[],
);
@@ -331,6 +445,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
(scrollElement: HTMLDivElement) => {
const scrollTop = scrollElement.scrollTop;
scrollTopRef.current = scrollTop;
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
scheduleHeaderPaint();
updateInteractionFiles(scrollTop);
if (hasHoveredAffordanceRef.current) {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
@@ -355,7 +472,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const nextTop = Math.round(requestedTop * ratio) / ratio;
if (nextTop !== currentWindow.top) schedulePaint(false);
},
[schedulePaint, viewport.height],
[scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
);
useEffect(() => {
const scroll = scrollRef.current;
@@ -420,8 +537,20 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[pointHit, schedulePaint],
);
const updateActiveHeader = useCallback(
(target: EventTarget | null) => {
const header =
target instanceof Element ? target.closest<HTMLElement>("[data-diff-header]") : null;
const activeHeaderPath = header?.dataset.diffHeaderPath ?? null;
if (activeHeaderPathRef.current === activeHeaderPath) return;
activeHeaderPathRef.current = activeHeaderPath;
scheduleHeaderPaint();
},
[scheduleHeaderPaint],
);
const pointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
updateActiveHeader(event.target);
const drag = dragRef.current;
if (drag) {
drag.moved = hasPointerDragStarted({
@@ -463,7 +592,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint();
},
[pointHit, schedulePaint, viewport.width],
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
);
const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@@ -505,6 +634,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const cancelPointer = useCallback(() => {
dragRef.current = null;
}, []);
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
const copy = useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
if (!selectionRef.current) return;
@@ -554,12 +684,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
onPointerUp={pointerUp}
onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer}
onPointerLeave={pointerLeave}
onCopy={copy}
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file) => (
{interactionFiles.map((file) => (
<WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader
file={file}
@@ -567,10 +698,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
canvasRendered
/>
</WebFileHeaderSection>
))}
{model.files
{interactionFiles
.filter((file) => !file.isCollapsed && !model.wrapLines)
.map((file) => (
<HorizontalScroll
@@ -609,6 +741,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
: null}
</div>
</div>
<canvas
ref={headerCanvasRef}
data-testid="git-diff-header-canvas"
style={HEADER_CANVAS_STYLE}
/>
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
{hoveredAffordance?.hit.target && reviewActions ? (
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
@@ -686,6 +823,7 @@ function WebFileHeaderSection({
<div style={style}>
<div
data-diff-header="true"
data-diff-header-path={file.path}
style={STICKY_HEADER_STYLE}
onMouseDown={preventDocumentMouseSelection}
>
@@ -766,6 +904,7 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
right: 0,
zIndex: 3,
pointerEvents: "none",
userSelect: "none",
};
const STICKY_HEADER_STYLE: React.CSSProperties = {
position: "sticky",
@@ -789,6 +928,12 @@ const CANVAS_STYLE: React.CSSProperties = {
zIndex: 1,
pointerEvents: "none",
};
const HEADER_CANVAS_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
zIndex: 3,
pointerEvents: "none",
};
const AFFORDANCE_STYLE: ViewStyle = {
position: "absolute",
zIndex: 5,
@@ -808,6 +953,7 @@ function emptyDiffDocumentModel(input: {
layout: input.layout,
wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth,
reviewGeometryKey: "",
};
}
@@ -23,6 +23,66 @@ export interface NativeTextLayout {
paragraphs: Array<Array<Array<SkParagraph | null>>>;
}
export interface NativeHeaderTextLayout {
families: string[] | undefined;
fontSize: number;
statFontSize: number;
palette: Pick<DiffPalette, "foreground" | "foregroundMuted" | "statusSuccess" | "statusDanger">;
}
export interface NativeShapedHeaderText {
paragraph: SkParagraph;
width: number;
height: number;
}
export type NativeHeaderTextTone = keyof NativeHeaderTextLayout["palette"];
export function createNativeHeaderTextLayout(input: {
configuredFamily: string;
fontSize: number;
statFontSize: number;
palette: NativeHeaderTextLayout["palette"];
}): NativeHeaderTextLayout {
const configured = input.configuredFamily
.split(",")
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
// These are React Native's platform-default UI sentinels, not custom faces.
// Omitting the family lets Skia resolve the same platform system font.
.filter((family) => family && family !== "normal" && family !== "system-ui");
const families = configured.length > 0 ? [...new Set(configured)] : undefined;
return {
families,
fontSize: input.fontSize,
statFontSize: input.statFontSize,
palette: input.palette,
};
}
export function shapeNativeHeaderText(input: {
layout: NativeHeaderTextLayout;
text: string;
size: "body" | "stat";
tone: NativeHeaderTextTone;
maximumWidth?: number;
}): NativeShapedHeaderText {
const paragraph = Skia.ParagraphBuilder.Make({ maxLines: 1, ellipsis: "…" })
.pushStyle({
...(input.layout.families ? { fontFamilies: input.layout.families } : {}),
fontSize: input.size === "body" ? input.layout.fontSize : input.layout.statFontSize,
color: Skia.Color(input.layout.palette[input.tone]),
})
.addText(input.text)
.pop()
.build();
paragraph.layout(input.maximumWidth ?? PARAGRAPH_WIDTH);
return {
paragraph,
width: paragraph.getLongestLine(),
height: paragraph.getHeight(),
};
}
export interface NativeTextLayoutStore {
font: SkFont;
asciiMetrics: CachedAsciiTextMetrics;
@@ -61,9 +61,20 @@ export interface DiffPalette {
deletionBackground: string;
emptyBackground: string;
selection: string;
headerActiveSurface: string;
headerBorder: string;
statusSuccess: string;
statusDanger: string;
statusWarning: string;
syntax: Record<string, string>;
}
export interface DiffHeaderTypography {
family: string;
size: number;
statSize: number;
}
export interface DiffTokenRun {
start: number;
end: number;
@@ -150,6 +161,7 @@ export interface DiffDocumentModel {
layout: "unified" | "split";
wrapLines: boolean;
viewportWidth: number;
reviewGeometryKey: string;
}
export interface TextMeasurer {
@@ -168,6 +180,7 @@ export interface BuildDiffDocumentModelInput {
palette: DiffPalette;
reviewActions?: InlineReviewActions;
labels: { binary: string; tooLarge: string };
materializationWindow?: { top: number; height: number };
/** A geometry-compatible model whose unchanged file measurements may be reused. */
reuseFrom?: readonly DiffDocumentModel[];
}
@@ -206,6 +219,7 @@ export type DiffScrollAnchor =
export type DiffSurfaceProps = DiffDocumentProps & {
palette: DiffPalette;
headerTypography: DiffHeaderTypography;
collapsedFilePaths: ReadonlySet<string>;
onToggleFile: (path: string) => void;
selectedPath: string | null;
@@ -1,7 +1,12 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
DiffPalette,
TextMeasurer,
} from "./types";
const palette: DiffPalette = {
surface: "#000",
@@ -15,6 +20,11 @@ const palette: DiffPalette = {
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: { keyword: "purple" },
};
@@ -137,6 +147,33 @@ describe("diff document workspace cache", () => {
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
});
it("materializes successive unwrapped windows without remeasuring retained files", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
const { measureText, stats } = countingMeasurer();
const first = cache.buildModel(
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
);
const firstCalls = stats.calls;
const secondFile = first.files[1]!;
const second = cache.buildModel(
modelInput(files, measureText, {
materializationWindow: {
top: secondFile.top,
height: secondFile.bottom - secondFile.top,
},
}),
);
expect(firstCalls).toBeGreaterThan(0);
expect(stats.calls).toBeGreaterThan(firstCalls);
expect(measuredFragmentCount(first, 0)).toBeGreaterThan(0);
expect(measuredFragmentCount(first, 1)).toBe(0);
expect(measuredFragmentCount(second, 0)).toBeGreaterThan(0);
expect(measuredFragmentCount(second, 1)).toBeGreaterThan(0);
});
it("shares loaded typography and its text measurer across mounts", async () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
@@ -171,3 +208,13 @@ describe("diff document workspace cache", () => {
expect(resource.isReady()).toBe(true);
});
});
function measuredFragmentCount(model: DiffDocumentModel, fileIndex: number): number {
const file = model.files[fileIndex];
if (!file) throw new Error(`Expected file ${fileIndex}`);
return model.rows
.slice(file.rowStart, file.rowEnd)
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
}
@@ -1,6 +1,6 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import invariant from "tiny-invariant";
import { buildDiffDocumentModel, retainReusableModels } from "./model";
import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
@@ -117,17 +117,11 @@ function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">):
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
const reviewGeometry = input.reviewActions
? {
comments: [...input.reviewActions.commentsByTarget.entries()]
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right))),
editor: input.reviewActions.editor
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
: null,
}
const reviewGeometry = reviewGeometryKey(input.reviewActions);
const materializationWindow = input.materializationWindow
? [input.materializationWindow.top, input.materializationWindow.height]
: null;
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
}
function typographyKey(typography: DiffTypography): string {
@@ -150,6 +144,11 @@ function paletteKey(palette: DiffPalette): string {
palette.deletionBackground,
palette.emptyBackground,
palette.selection,
palette.headerActiveSurface,
palette.headerBorder,
palette.statusSuccess,
palette.statusDanger,
palette.statusWarning,
syntax,
]);
}
@@ -1,51 +1,11 @@
import { describe, expect, it } from "vitest";
import {
reduceFileHeaderPress,
shouldActivateStickyHeaderPress,
type FileHeaderPressEvent,
type FileHeaderPressPhase,
} from "./file-header-interaction";
const pressOrigin = { startedAt: 1_000, pageX: 20, pageY: 30 };
describe("file header interaction arbitration", () => {
it("activates an unhandled native sticky-header tap on press out", () => {
expect(
shouldActivateStickyHeaderPress({
enabled: true,
native: true,
pressHandled: false,
stickyPressFallback: true,
pressOrigin,
releasedAt: 1_500,
pageX: 32,
pageY: 30,
}),
).toBe(true);
});
it.each([
["an already handled long press", { pressHandled: true }],
["a non-native release", { native: false }],
["a non-sticky header", { stickyPressFallback: false }],
["a held press", { releasedAt: 1_501 }],
["a moved press", { pageX: 33 }],
])("does not activate %s", (_label, override) => {
expect(
shouldActivateStickyHeaderPress({
enabled: true,
native: true,
pressHandled: false,
stickyPressFallback: true,
pressOrigin,
releasedAt: 1_500,
pageX: 32,
pageY: 30,
...override,
}),
).toBe(false);
});
function run(events: FileHeaderPressEvent[]) {
let phase: FileHeaderPressPhase = "idle";
const effects: string[] = [];
@@ -57,64 +17,17 @@ describe("file header interaction arbitration", () => {
return effects;
}
it("lets a normal press claim a deferred sticky release exactly once", () => {
expect(
run([
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "press" },
{ type: "fallback" },
]),
).toEqual(["defer-activate", "activate"]);
it("activates an actual press", () => {
expect(run([{ type: "press-in" }, { type: "press" }])).toEqual(["activate"]);
});
it("keeps a duplicated wrapper press-out eligible for one fallback", () => {
expect(
run([
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "fallback" },
]),
).toEqual(["defer-activate", "activate"]);
it("does not activate when scrolling cancels the press", () => {
expect(run([{ type: "press-in" }])).toEqual([]);
});
it("falls back once for a canceled sticky press", () => {
expect(
run([
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "fallback" },
]),
).toEqual(["defer-activate", "activate"]);
});
it.each([
[
"canceled non-sticky",
[{ type: "press-in" }, { type: "press-out", stickyFallbackEligible: false }],
],
[
"moved",
[
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: false },
{ type: "fallback" },
],
],
] as const)("does not activate a %s sequence", (_label, events) => {
expect(run([...events])).toEqual([]);
});
it("selects a long press without activating when Pressability later emits press-out/press", () => {
expect(
run([
{ type: "press-in" },
{ type: "long-press" },
{ type: "press-out", stickyFallbackEligible: false },
{ type: "press" },
{ type: "fallback" },
]),
).toEqual(["select"]);
it("selects a long press without activating when Pressability later emits press", () => {
expect(run([{ type: "press-in" }, { type: "long-press" }, { type: "press" }])).toEqual([
"select",
]);
});
});
+15 -140
View File
@@ -1,9 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useRef } from "react";
import type { LayoutChangeEvent } from "react-native";
import { isNative } from "@/constants/platform";
const STICKY_PRESS_MAX_DURATION_MS = 500;
const STICKY_PRESS_MAX_DISTANCE = 12;
interface FileHeaderInteractionInput {
path: string;
@@ -11,47 +7,22 @@ interface FileHeaderInteractionInput {
onSelect?: (path: string) => void;
onActivate?: (path: string) => void;
onLayout?: (height: number) => void;
stickyPressFallback?: boolean;
}
interface PressPointEvent {
nativeEvent: { pageX: number; pageY: number };
}
interface PressOrigin {
startedAt: number;
pageX: number;
pageY: number;
}
interface StickyPressReleaseInput {
enabled: boolean;
native: boolean;
pressHandled: boolean;
stickyPressFallback: boolean;
pressOrigin: PressOrigin | null;
releasedAt: number;
pageX: number;
pageY: number;
}
export interface FileHeaderInteraction {
activate: () => void;
select: () => void;
onLayout: (event: LayoutChangeEvent) => void;
onPressIn: (event: PressPointEvent) => void;
onPressOut: (event: PressPointEvent) => void;
onPressIn: () => void;
onLongPress: () => void;
}
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long";
export type FileHeaderPressPhase = "idle" | "pressed" | "long" | "handled";
export type FileHeaderPressEvent =
| { type: "press-in" }
| { type: "press-out"; stickyFallbackEligible: boolean }
| { type: "press" }
| { type: "long-press" }
| { type: "fallback" };
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
| { type: "long-press" };
export type FileHeaderPressEffect = "none" | "select" | "activate";
export function reduceFileHeaderPress(
phase: FileHeaderPressPhase,
@@ -59,62 +30,14 @@ export function reduceFileHeaderPress(
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
if (event.type === "long-press") return { phase: "long", effect: "select" };
if (event.type === "press-out") {
if (phase === "released" || phase === "handled" || phase === "long") {
return { phase, effect: "none" };
}
return event.stickyFallbackEligible && phase === "pressed"
? { phase: "released", effect: "defer-activate" }
: { phase: "idle", effect: "none" };
}
if (event.type === "press") {
return phase === "long" || phase === "handled"
? { phase: "handled", effect: "none" }
: { phase: "handled", effect: "activate" };
}
return phase === "released"
? { phase: "handled", effect: "activate" }
: { phase, effect: "none" };
}
export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): boolean {
if (
!input.enabled ||
!input.native ||
input.pressHandled ||
!input.stickyPressFallback ||
!input.pressOrigin
) {
return false;
}
const durationMs = input.releasedAt - input.pressOrigin.startedAt;
const dx = input.pageX - input.pressOrigin.pageX;
const dy = input.pageY - input.pressOrigin.pageY;
return (
durationMs <= STICKY_PRESS_MAX_DURATION_MS && Math.hypot(dx, dy) <= STICKY_PRESS_MAX_DISTANCE
);
return phase === "long" || phase === "handled"
? { phase: "handled", effect: "none" }
: { phase: "handled", effect: "activate" };
}
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
const {
path,
enabled,
onSelect,
onActivate,
onLayout: notifyLayout,
stickyPressFallback = false,
} = input;
const pressHandledRef = useRef(false);
const pressOriginRef = useRef<PressOrigin | null>(null);
const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
const phaseRef = useRef<FileHeaderPressPhase>("idle");
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearFallback = useCallback(() => {
if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}, []);
useEffect(() => clearFallback, [clearFallback]);
const select = useCallback(() => {
if (enabled) onSelect?.(path);
@@ -122,14 +45,12 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
const activate = useCallback(() => {
if (!enabled) return;
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
phaseRef.current = transition.phase;
if (transition.effect !== "activate") return;
pressHandledRef.current = true;
select();
onActivate?.(path);
}, [clearFallback, enabled, onActivate, path, select]);
}, [enabled, onActivate, path, select]);
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -138,61 +59,15 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
[notifyLayout],
);
const onPressIn = useCallback(
(event: PressPointEvent) => {
clearFallback();
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
pressHandledRef.current = false;
pressOriginRef.current = {
startedAt: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
},
[clearFallback],
);
const onPressIn = useCallback(() => {
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
}, []);
const onLongPress = useCallback(() => {
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
phaseRef.current = transition.phase;
pressHandledRef.current = true;
if (transition.effect === "select") select();
}, [clearFallback, select]);
}, [select]);
const onPressOut = useCallback(
(event: PressPointEvent) => {
const eligible = shouldActivateStickyHeaderPress({
enabled,
native: isNative,
pressHandled: pressHandledRef.current,
stickyPressFallback,
pressOrigin: pressOriginRef.current,
releasedAt: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
});
const transition = reduceFileHeaderPress(phaseRef.current, {
type: "press-out",
stickyFallbackEligible: eligible,
});
phaseRef.current = transition.phase;
if (transition.effect === "defer-activate") {
clearFallback();
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null;
const fallback = reduceFileHeaderPress(phaseRef.current, { type: "fallback" });
phaseRef.current = fallback.phase;
if (fallback.effect === "activate") {
pressHandledRef.current = true;
select();
onActivate?.(path);
}
}, 0);
}
},
[clearFallback, enabled, onActivate, path, select, stickyPressFallback],
);
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
return { activate, select, onLayout, onPressIn, onLongPress };
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { allocateDiffHeaderTextWidths } from "./file-header-presentation";
describe("diff file header text allocation", () => {
it("gives the filename its full width before truncating the directory", () => {
expect(
allocateDiffHeaderTextWidths({ available: 120, nameWidth: 80, directoryWidth: 100 }),
).toEqual({ name: 80, directory: 36 });
});
it("uses all available width for a filename that cannot fit", () => {
expect(
allocateDiffHeaderTextWidths({ available: 60, nameWidth: 80, directoryWidth: 100 }),
).toEqual({ name: 60, directory: 0 });
});
it("does not truncate a fitting filename merely to reserve the gap", () => {
expect(
allocateDiffHeaderTextWidths({ available: 82, nameWidth: 80, directoryWidth: 100 }),
).toEqual({ name: 80, directory: 0 });
});
});
@@ -0,0 +1,47 @@
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export const DIFF_FILE_HEADER_HEIGHT = 30;
export const DIFF_FILE_HEADER_CONTENT_HEIGHT = 28;
export const DIFF_FILE_HEADER_LEFT = 12;
export const DIFF_FILE_HEADER_RIGHT = 8;
export const DIFF_FILE_HEADER_ICON_SIZE = 14;
export const DIFF_FILE_HEADER_TEXT_GAP = 4;
export function allocateDiffHeaderTextWidths(input: {
available: number;
nameWidth: number;
directoryWidth: number;
}): { name: number; directory: number } {
const available = Math.max(0, input.available);
const nameWidth = Math.max(0, input.nameWidth);
const directoryWidth = Math.max(0, input.directoryWidth);
if (nameWidth >= available || directoryWidth === 0) {
return { name: Math.min(nameWidth, available), directory: 0 };
}
const directoryAvailable = Math.max(0, available - nameWidth - DIFF_FILE_HEADER_TEXT_GAP);
return { name: nameWidth, directory: Math.min(directoryWidth, directoryAvailable) };
}
export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function fileNameForPath(path: string): string {
return path.split("/").pop() ?? path;
}
export function directorySuffix(path: string): string {
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
}
export function diffFileChangeKind(file: {
isNew: boolean;
isDeleted: boolean;
}): "added" | "deleted" | "modified" {
if (file.isNew) return "added";
if (file.isDeleted) return "deleted";
return "modified";
}
+87 -27
View File
@@ -1,5 +1,10 @@
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import {
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import { DiffStat } from "@/components/diff-stat";
@@ -17,6 +22,12 @@ import {
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useFileHeaderInteraction } from "@/git/file-header-interaction";
import {
diffFileChangeKind,
directorySuffix,
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -43,14 +54,8 @@ export interface FileHeaderProps {
onRevert?: (path: string, oldPath?: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
function fileNameForPath(path: string): string {
return path.split("/").pop() ?? path;
}
function directorySuffix(path: string): string {
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
canvasRendered?: boolean;
onActiveChange?: (active: boolean) => void;
}
function fileHeaderAccessibilityState(input: {
@@ -68,12 +73,22 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
return showsBodyState ? bodyVisible : undefined;
}
function useFileHeaderHover(enabled: boolean) {
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
if (!canvasRendered) return undefined;
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
}
function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean) => void) {
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => {
if (enabled) setIsHovered(true);
}, [enabled]);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
if (!enabled) return;
setIsHovered(true);
onActiveChange?.(true);
}, [enabled, onActiveChange]);
const handlePointerLeave = useCallback(() => {
setIsHovered(false);
onActiveChange?.(false);
}, [onActiveChange]);
return { isHovered, handlePointerEnter, handlePointerLeave };
}
@@ -96,7 +111,8 @@ function fileHeaderInteractionStyle(input: {
return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active;
}
function fileHeaderPressFeedbackStyle(showsBodyState: boolean) {
function fileHeaderPressFeedbackStyle(showsBodyState: boolean, canvasRendered: boolean) {
if (canvasRendered) return undefined;
return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active;
}
@@ -115,9 +131,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
}
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
if (file.isNew) return "added";
if (file.isDeleted) return "deleted";
return "modified";
return diffFileChangeKind(file);
}
function FileHeaderMenu({
@@ -181,9 +195,11 @@ export const FileHeader = memo(function FileHeader({
onSelect,
onHeaderHeightChange,
testID,
canvasRendered = false,
onActiveChange,
...actions
}: FileHeaderProps) {
const hover = useFileHeaderHover(interactive);
const hover = useFileHeaderHover(interactive, onActiveChange);
const dragSourceRef = useWorkspaceFileDragSource({
enabled: interactive,
disabled: file.isDeleted,
@@ -196,7 +212,6 @@ export const FileHeader = memo(function FileHeader({
enabled: interactive,
onSelect,
onActivate,
stickyPressFallback: showsBodyState,
onLayout: useCallback(
(height: number) => onHeaderHeightChange?.(file.path, height),
[file.path, onHeaderHeightChange],
@@ -221,6 +236,13 @@ export const FileHeader = memo(function FileHeader({
],
[depth, hover.isHovered, isSelected, showsBodyState],
);
const canvasPressableStyle = useCallback(
(state: PressableStateCallbackType) => [
pressableStyle(state),
canvasRendered && styles.canvasInteraction,
],
[canvasRendered, pressableStyle],
);
const accessibilityState = useMemo(
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
[bodyVisible, isSelected, showsBodyState],
@@ -260,36 +282,67 @@ export const FileHeader = memo(function FileHeader({
</View>
</View>
);
const renderedContent = canvasRendered ? (
<View ref={dragSourceRef} style={styles.canvasInteractionContent} />
) : (
content
);
const accessibilityLabel = fileHeaderAccessibilityLabel(canvasRendered, file);
const handlePressIn = useCallback(
(_event: GestureResponderEvent) => {
onActiveChange?.(true);
interaction.onPressIn();
},
[interaction, onActiveChange],
);
const handlePressOut = useCallback(
(_event: GestureResponderEvent) => {
onActiveChange?.(false);
},
[onActiveChange],
);
let trigger: ReactElement;
if (interactive) {
trigger = (
<ContextMenuTrigger
testID={testID ? `${testID}-toggle` : undefined}
style={pressableStyle}
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
cancelable={false}
onPressIn={interaction.onPressIn}
onPressOut={interaction.onPressOut}
style={canvasPressableStyle}
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState, canvasRendered)}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onLongPress={interaction.onLongPress}
onContextMenu={interaction.select}
onPress={interaction.activate}
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
aria-selected={isSelected}
>
{content}
{renderedContent}
</ContextMenuTrigger>
);
} else {
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
trigger = (
<View
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
accessibilityLabel={accessibilityLabel}
>
{renderedContent}
</View>
);
}
return (
<View
style={[styles.container, fileHeaderContainerStyle(showsBodyState)]}
style={[
styles.container,
fileHeaderContainerStyle(showsBodyState),
canvasRendered && styles.canvasInteraction,
]}
onLayout={interaction.onLayout}
onPointerEnter={hover.handlePointerEnter}
onPointerLeave={hover.handlePointerLeave}
testID={testID}
accessibilityLabel={accessibilityLabel}
>
<ContextMenu>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
@@ -347,6 +400,13 @@ const styles = StyleSheet.create((theme) => ({
},
documentActive: { backgroundColor: theme.colors.surface1 },
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
canvasInteraction: {
backgroundColor: "transparent",
borderColor: "transparent",
elevation: 0,
shadowOpacity: 0,
},
canvasInteractionContent: { flex: 1, minWidth: 0 },
left: {
flexDirection: "row",
alignItems: "center",
@@ -28,3 +28,11 @@ export function resolveKeyboardShift(input: {
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
export function shouldReconcileHiddenKeyboardEnd(input: {
height: number;
progress: number;
}): boolean {
"worklet";
return !(input.height > 0) || !(input.progress > 0);
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
shouldReconcileHiddenKeyboardEnd,
resolveKeyboardShift,
shouldUseCompactExplorerKeyboardPadding,
} from "./keyboard-shift-policy";
@@ -42,6 +43,23 @@ describe("resolveKeyboardShift", () => {
});
});
describe("shouldReconcileHiddenKeyboardEnd", () => {
it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => {
expect(
shouldReconcileHiddenKeyboardEnd({
height: 0,
progress: 0,
}),
).toBe(true);
expect(
shouldReconcileHiddenKeyboardEnd({
height: 320,
progress: 1,
}),
).toBe(false);
});
});
describe("shouldUseCompactExplorerKeyboardPadding", () => {
it("keeps the changes viewport stable while preserving padding for other tabs", () => {
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
@@ -15,6 +15,7 @@ import {
import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
shouldReconcileHiddenKeyboardEnd,
} from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
@@ -34,9 +35,9 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
{
onEnd: (event) => {
"worklet";
if (isIos) {
keyboardHeight.value = -event.height;
keyboardProgress.value = event.progress;
if (isIos && shouldReconcileHiddenKeyboardEnd(event)) {
keyboardHeight.value = 0;
keyboardProgress.value = 0;
}
},
},
+45 -14
View File
@@ -54,8 +54,8 @@ class MobilePanelsScenario {
return this;
}
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
this.dispatch({ type: "animation.finished", revision, target });
settleAt(position: number) {
this.dispatch({ type: "position.changed", position });
return this;
}
@@ -90,20 +90,53 @@ describe("mobile panel ownership", () => {
success: true,
target: "agent-list",
}).state;
const commanded = transitionMobilePanel(released, {
const commandTransition = transitionMobilePanel(released, {
type: "command",
selection: { target: "agent-list", revision: 1 },
}).state;
});
const commanded = commandTransition.state;
expect(isMobilePanelActive(dragging, "agent-list")).toBe(false);
expect(isMobilePanelActive(released, "agent-list")).toBe(false);
expect(isMobilePanelActive(commanded, "agent-list")).toBe(false);
expect(commandTransition.animationTarget).toBeUndefined();
const settled = transitionMobilePanel(commanded, {
type: "animation.finished",
revision: 1,
type: "position.changed",
position: -1,
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
});
it("activates immediately when the command arrives after position settles", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
const dragging = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
const released = transitionMobilePanel(dragging, {
type: "gesture.finish",
startedRevision: 0,
success: true,
target: "agent-list",
}).state;
const reachedAnchor = transitionMobilePanel(released, {
type: "position.changed",
position: -1,
}).state;
expect(isMobilePanelActive(reachedAnchor, "agent-list")).toBe(false);
const commanded = transitionMobilePanel(reachedAnchor, {
type: "command",
selection: { target: "agent-list", revision: 1 },
});
expect(commanded.animationTarget).toBeUndefined();
const settled = transitionMobilePanel(commanded.state, {
type: "position.changed",
position: -1,
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
});
@@ -135,9 +168,8 @@ describe("mobile panel ownership", () => {
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
const settled = transitionMobilePanel(closing, {
type: "animation.finished",
revision: 1,
target: "agent",
type: "position.changed",
position: 0,
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
expect(isMobilePanelActive(settled, "agent")).toBe(true);
@@ -146,7 +178,7 @@ describe("mobile panel ownership", () => {
it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list").finishAnimation("agent-list");
panels.command("agent-list").settleAt(-1);
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
@@ -154,7 +186,7 @@ describe("mobile panel ownership", () => {
revision: 1,
});
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
panels.command("agent").command("file-explorer").settleAt(1);
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
@@ -205,13 +237,12 @@ describe("mobile panel ownership", () => {
expect(panels.commits).toEqual([]);
});
it("keeps the latest rapid command and rejects stale animation completion", () => {
it("keeps the latest rapid command when position reaches a stale anchor", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list");
const staleRevision = panels.snapshot().revision;
panels.command("agent").command("file-explorer");
panels.finishAnimation("agent-list", staleRevision);
panels.settleAt(-1);
expect(panels.snapshot()).toEqual({
target: "file-explorer",
+30 -22
View File
@@ -21,6 +21,32 @@ export interface MobilePanelTransition {
state: MobilePanelMotionState;
}
export function getMobilePanelAnchor(panel: MobilePanelView): number {
"worklet";
if (panel === "agent-list") {
return -1;
}
if (panel === "file-explorer") {
return 1;
}
return 0;
}
function settleMobilePanelAtPosition(
state: MobilePanelMotionState,
position: number,
): MobilePanelTransition {
"worklet";
const isCanonicalMotion = state.target === state.motionTarget;
const isAtTarget = Math.abs(position - getMobilePanelAnchor(state.target)) <= 0.002;
if (state.gesture || !isCanonicalMotion || !isAtTarget || state.settledTarget === state.target) {
return { state };
}
return {
state: { ...state, settledTarget: state.target },
};
}
export type MobilePanelEvent =
| { type: "command"; selection: MobilePanelSelection }
| { type: "gesture.begin"; origin: MobilePanelView }
@@ -30,7 +56,7 @@ export type MobilePanelEvent =
success: boolean;
target: MobilePanelView;
}
| { type: "animation.finished"; revision: number; target: MobilePanelView };
| { type: "position.changed"; position: number };
export function createMobilePanelMotionState(
selection: MobilePanelSelection,
@@ -52,8 +78,9 @@ export function transitionMobilePanel(
if (event.selection.revision <= state.revision) {
return { state };
}
const continuesCurrentMotion = state.motionTarget === event.selection.target;
return {
animationTarget: event.selection.target,
animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
state: {
...state,
...event.selection,
@@ -101,26 +128,7 @@ export function transitionMobilePanel(
};
}
const ownsCurrentRevision = event.revision === state.revision;
const isCanonicalTarget = event.target === state.target;
const isCurrentMotionTarget = event.target === state.motionTarget;
if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) {
return { state };
}
return {
state: { ...state, settledTarget: event.target },
};
}
export function getMobilePanelAnchor(panel: MobilePanelView): number {
"worklet";
if (panel === "agent-list") {
return -1;
}
if (panel === "file-explorer") {
return 1;
}
return 0;
return settleMobilePanelAtPosition(state, event.position);
}
export function canBeginMobilePanelGesture(
+22 -22
View File
@@ -15,6 +15,7 @@ import type { GestureType } from "react-native-gesture-handler";
import {
cancelAnimation,
Easing,
useAnimatedReaction,
useSharedValue,
withTiming,
type SharedValue,
@@ -104,6 +105,22 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
setActivePanel(panel);
}, []);
useAnimatedReaction(
() => ({ motionState: motionState.value, position: position.value }),
({ motionState: currentState, position: currentPosition }) => {
const settled = transitionMobilePanel(currentState, {
type: "position.changed",
position: currentPosition,
});
if (settled.state === currentState) {
return;
}
motionState.value = settled.state;
scheduleOnRN(publishActivePanel, settled.state.settledTarget, settled.state.revision);
},
[motionState, position, publishActivePanel],
);
const animateTransition = useCallback(
(transition: MobilePanelTransition) => {
"worklet";
@@ -111,29 +128,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
return;
}
const target = transition.animationTarget;
const revision = transition.state.revision;
position.value = withTiming(
getMobilePanelAnchor(target),
{ duration: ANIMATION_DURATION, easing: ANIMATION_EASING },
(finished) => {
if (!finished) {
return;
}
const currentState = motionState.value;
const settled = transitionMobilePanel(currentState, {
type: "animation.finished",
revision,
target,
});
if (settled.state === currentState) {
return;
}
motionState.value = settled.state;
scheduleOnRN(publishActivePanel, target, revision);
},
);
position.value = withTiming(getMobilePanelAnchor(target), {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
},
[motionState, position, publishActivePanel],
[position],
);
const applySelection = useCallback(
@@ -1,4 +1,3 @@
import { Buffer } from "buffer";
import { z } from "zod";
import {
AgentStatusSchema,
@@ -715,8 +714,32 @@ function pendingRowKey(key: ReplicaRowKey): string {
return `${key.serverId}\u0000${rowKey(key)}`;
}
function payloadBytes(payload: string): number {
return Buffer.byteLength(payload, "utf8");
// Budget accounting runs over every stored row of a touched host on each persist. Rows are
// immutable once stored, so their size is computed once. The count itself avoids the JS Buffer
// polyfill, which materialises the whole byte array just to measure it.
const rowBytesCache = new WeakMap<ReplicaRow, number>();
function utf8ByteLength(text: string): number {
let bytes = 0;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff) {
bytes += 4;
index += 1;
} else bytes += 3;
}
return bytes;
}
function rowBytes(row: ReplicaRow): number {
let bytes = rowBytesCache.get(row);
if (bytes === undefined) {
bytes = utf8ByteLength(row.payload);
rowBytesCache.set(row, bytes);
}
return bytes;
}
function parseJsonPayload(payload: string): unknown {
@@ -1053,7 +1076,7 @@ export class ReplicaCache {
this.storedRows.delete(oldServerId);
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
this.storedRows.set(newServerId, newRows);
const bytes = [...newRows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0);
const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0);
this.hostBytes.delete(oldServerId);
this.hostBytes.set(newServerId, bytes);
this.totalBytes += bytes;
@@ -1237,17 +1260,17 @@ export class ReplicaCache {
const previous = rows?.get(rowKey(key));
if (previous) {
rows?.delete(rowKey(key));
this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload));
this.adjustHostBytes(key.serverId, -rowBytes(previous));
}
touchedServerIds.add(key.serverId);
}
for (const row of changes.upserts) {
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
const previous = rows.get(rowKey(row));
const previousBytes = previous ? payloadBytes(previous.payload) : 0;
const previousBytes = previous ? rowBytes(previous) : 0;
rows.set(rowKey(row), row);
this.storedRows.set(row.serverId, rows);
this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes);
this.adjustHostBytes(row.serverId, rowBytes(row) - previousBytes);
touchedServerIds.add(row.serverId);
}
for (const serverId of touchedServerIds) {
@@ -1288,7 +1311,7 @@ export class ReplicaCache {
for (const [serverId, rows] of projectedRows) {
projectedBytes.set(
serverId,
[...rows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0),
[...rows.values()].reduce((sum, row) => sum + rowBytes(row), 0),
);
}
@@ -1371,7 +1394,7 @@ export class ReplicaCache {
continue;
}
const rows = new Map(host.rows.map((row) => [rowKey(row), row]));
const bytes = host.rows.reduce((sum, row) => sum + payloadBytes(row.payload), 0);
const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0);
this.storedRows.set(host.serverId, rows);
this.hostBytes.set(host.serverId, bytes);
this.totalBytes += bytes;
+27 -2
View File
@@ -70,7 +70,6 @@ describe("deriveStreamTurnTiming", () => {
});
assert.deepEqual(timing.byAssistantId.get("a1"), {
startedAt: userAt,
completedAt: assistantAt,
durationMs: 7000,
});
@@ -93,11 +92,37 @@ describe("deriveStreamTurnTiming", () => {
});
const expected = {
startedAt: userAt,
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
});
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
const firstTurnAt = new Date("2026-05-15T00:00:00.000Z");
const hiddenPromptTurnAt = new Date("2026-05-15T00:01:07.000Z");
const timing = deriveStreamTurnTiming({
isTurnActive: false,
activeTurnStartedAt: null,
tail: [
{ ...user("u1", firstTurnAt), turnId: "turn-1" },
{
...assistant("a1", new Date("2026-05-15T00:00:07.000Z")),
turnId: "turn-1",
},
{
...assistant("hidden-prompt-a1", new Date("2026-05-15T00:01:03.000Z")),
turnId: "turn-2",
},
{ ...assistant("hidden-prompt-a2", hiddenPromptTurnAt), turnId: "turn-2" },
],
head: [],
});
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
completedAt: hiddenPromptTurnAt,
durationMs: null,
});
});
});
+5 -9
View File
@@ -2,9 +2,8 @@ import type { StreamItem } from "@/types/stream";
import { startsNewTurn } from "@/agent-stream/turn-membership";
export interface TurnTiming {
startedAt: Date;
completedAt: Date;
durationMs: number;
durationMs: number | null;
}
export interface StreamTurnTiming {
@@ -25,13 +24,14 @@ export function deriveStreamTurnTiming(params: {
let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => {
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
if (!currentLastItemAt || currentAssistantIds.length === 0) {
return;
}
const timing: TurnTiming = {
startedAt: currentUserAt,
completedAt: currentLastItemAt,
durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()),
durationMs: currentUserAt
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
: null,
};
for (const id of currentAssistantIds) {
byAssistantId.set(id, timing);
@@ -45,10 +45,6 @@ export function deriveStreamTurnTiming(params: {
currentLastItemAt = null;
currentAssistantIds = [];
}
if (!currentUserAt) {
previousItem = item;
return;
}
currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/server": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -34,13 +34,16 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin",
});
const cliPackageJson = JSON.parse(
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
) as { version: string };
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
name: "hello-plugin",
private: true,
version: "0.0.0",
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": "0.7.0",
"@getpaseo/plugin": cliPackageJson.version,
"@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0",
react: "19.1.0",
+21 -23
View File
@@ -57,10 +57,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 5",
descriptionFragment: "Latest release",
},
{
id: "claude-fable-5-1",
model: "Fable 5.1",
descriptionFragment: "Most powerful",
},
{
id: "claude-fable-5",
model: "Fable 5",
descriptionFragment: "Most powerful",
descriptionFragment: "Previous release",
},
{
id: "claude-opus-4-8[1m]",
@@ -161,18 +166,9 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
}
function assertClaudeModels(data: ProviderModel[]): void {
assert.strictEqual(
data.length,
EXPECTED_CLAUDE_CATALOG_MODELS.length,
"claude output should match the current catalog size",
);
const byId = new Map(data.map((model) => [model.id, model]));
const ids = [...byId.keys()].sort();
const expectedIds = EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort();
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog");
for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) {
const actualModel = byId.get(expectedModel.id);
@@ -187,6 +183,18 @@ function assertClaudeModels(data: ProviderModel[]): void {
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`,
);
}
const fable51Index = data.findIndex((model) => model.id === "claude-fable-5-1");
const fable5Index = data.findIndex((model) => model.id === "claude-fable-5");
assert.strictEqual(
fable5Index,
fable51Index + 1,
"Fable models should stay adjacent and newest-first",
);
assert(
!byId.has("claude-fable-5[1m]"),
"compatibility-only Fable aliases should not appear in CLI output",
);
}
try {
@@ -411,20 +419,10 @@ try {
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n").filter(Boolean);
assert.strictEqual(
lines.length,
EXPECTED_CLAUDE_CATALOG_MODELS.length,
"should have one line per Claude catalog model",
);
assert.deepStrictEqual(
[...lines].sort(),
[...claudeModelIdsFromJson].sort(),
"--quiet should print the same model IDs returned by --json",
);
assert.deepStrictEqual(
[...lines].sort(),
EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort(),
"--quiet should print the current Claude catalog IDs",
lines,
claudeModelIdsFromJson,
"--quiet should print the same ordered model IDs returned by --json",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
@@ -218,9 +218,12 @@ try {
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
);
assert(
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
`stop should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
`stop should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
);
assert(
capturedSupervisorLogs.includes("Server closed"),
`stop should run daemon cleanup before the worker exits, logs:\n${capturedSupervisorLogs}`,
);
assert(
!capturedSupervisorLogs.includes("cli_shutdown"),
@@ -234,9 +234,12 @@ try {
`restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
);
assert(
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
`restart should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
`restart should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
);
assert(
capturedSupervisorLogs.includes("Server closed"),
`restart should run daemon cleanup before replacing the worker, logs:\n${capturedSupervisorLogs}`,
);
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
} finally {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.7.0",
"version": "0.7.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0",
"version": "0.7.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.7.0",
"version": "0.7.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -44,8 +44,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -54,8 +54,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.7.0",
"version": "0.7.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0",
"@getpaseo/highlight": "0.7.0",
"@getpaseo/plugin": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -10,6 +10,15 @@ import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
@@ -172,9 +181,12 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n");
});
test("logs the worker shutdown reason before signaling the worker", async () => {
test("logs the worker shutdown reason before requesting graceful shutdown", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" });
setInterval(() => {}, 1000);
`,
@@ -184,17 +196,61 @@ describe("supervisor durable logging", () => {
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker requested shutdown"');
expect(result.log).toContain('"reason":"client_shutdown_rpc"');
expect(result.log).toContain('"msg":"Supervisor sending signal to worker"');
expect(result.log).toContain('"signal":"SIGTERM"');
expect(result.log).toContain('"msg":"Supervisor requesting graceful worker shutdown"');
expect(result.log).toContain('"workerPid":');
});
test("lets the worker clean up its descendant before supervised shutdown", async () => {
const result = await runSupervisorFixture({
workerSource: `
import { spawn } from "node:child_process";
const descendant = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ detached: true, stdio: "ignore" },
);
descendant.unref();
process.stdout.write(\`DESCENDANT_PID=\${descendant.pid}\\n\`);
process.on("message", (message) => {
if (message?.type !== "paseo:graceful-shutdown") return;
descendant.once("exit", () => {
process.stdout.write("GRACEFUL_CLEANUP_RAN\\n");
process.exit(0);
});
descendant.kill("SIGTERM");
});
process.send?.({ type: "paseo:shutdown", reason: "descendant_cleanup_probe" });
setInterval(() => {}, 1000);
`,
});
const descendantPid = Number.parseInt(
result.stdout.match(/DESCENDANT_PID=(\d+)/)?.[1] ?? "",
10,
);
expect(Number.isInteger(descendantPid)).toBe(true);
const descendantSurvived = isProcessRunning(descendantPid);
if (descendantSurvived) {
process.kill(descendantPid, "SIGKILL");
}
expect.soft(result.stdout).toContain("GRACEFUL_CLEANUP_RAN");
expect(descendantSurvived).toBe(false);
});
test("does not restart a worker based on heartbeat absence", async () => {
const result = await runSupervisorFixture({
timeoutMs: 20_000,
workerSource: `
import { existsSync, writeFileSync } from "node:fs";
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
const marker = process.argv[1] + ".started";
if (!existsSync(marker)) {
writeFileSync(marker, "started");
@@ -216,26 +272,22 @@ describe("supervisor durable logging", () => {
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
}, 25_000);
test.skipIf(isPlatform("win32"))(
"forces shutdown when a worker ignores SIGTERM",
async () => {
const result = await runSupervisorFixture({
timeoutMs: 15_000,
workerSource: `
process.on("SIGTERM", () => {});
test("forces shutdown when a worker ignores the graceful shutdown request", async () => {
const result = await runSupervisorFixture({
timeoutMs: 15_000,
workerSource: `
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
setInterval(() => {}, 1_000);
`,
});
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
expect(result.log).toContain('"signal":"SIGKILL"');
},
20_000,
);
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
expect(result.log).toContain(
'"msg":"Worker did not exit after graceful shutdown request; forcing process tree kill"',
);
}, 20_000);
test.skipIf(isPlatform("win32"))(
"restarts after worker exit while a descendant retains the worker stdio",
@@ -246,6 +298,9 @@ describe("supervisor durable logging", () => {
import { spawn } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
const marker = process.argv[1] + ".started";
if (!existsSync(marker)) {
writeFileSync(marker, "started");
@@ -255,7 +310,6 @@ describe("supervisor durable logging", () => {
{ detached: true, stdio: ["ignore", "inherit", "inherit"] },
);
descendant.unref();
process.on("SIGTERM", () => process.exit(0));
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
setInterval(() => {}, 1000);
} else {
+41 -12
View File
@@ -33,6 +33,11 @@ interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface SupervisorGracefulShutdownMessage {
type: "paseo:graceful-shutdown";
reason: string;
}
interface SupervisorOptions {
name: string;
startupMessage: string;
@@ -192,11 +197,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (child !== currentChild) {
return;
}
writeLifecycleLog("Worker did not exit after SIGTERM; forcing SIGKILL", {
reason,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
writeLifecycleLog(
"Worker did not exit after graceful shutdown request; forcing process tree kill",
{
reason,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
},
);
void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
writeLifecycleLog("Failed to force-kill worker process tree", {
error: error instanceof Error ? error.message : String(error),
@@ -327,17 +335,38 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
});
};
const signalWorker = (signal: NodeJS.Signals, reason: string): void => {
const requestWorkerShutdown = (reason: string): void => {
if (!child) {
return;
}
writeLifecycleLog("Supervisor sending signal to worker", {
const currentChild = child;
const message: SupervisorGracefulShutdownMessage = {
type: "paseo:graceful-shutdown",
reason,
};
writeLifecycleLog("Supervisor requesting graceful worker shutdown", {
reason,
signal,
supervisorPid: process.pid,
workerPid: child.pid ?? null,
workerPid: currentChild.pid ?? null,
});
if (!currentChild.connected) {
writeLifecycleLog("Graceful worker shutdown IPC unavailable", {
reason,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
return;
}
currentChild.send?.(message, (error) => {
if (error) {
writeLifecycleLog("Graceful worker shutdown IPC send failed", {
error: error instanceof Error ? error.message : String(error),
reason,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
}
});
child.kill(signal);
};
const requestRestart = (reason: string) => {
@@ -347,7 +376,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
restarting = true;
writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`);
signalWorker("SIGTERM", reason);
requestWorkerShutdown(reason);
scheduleForceKill(reason);
};
@@ -363,7 +392,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
exitSupervisor(0);
return;
}
signalWorker("SIGTERM", reason);
requestWorkerShutdown(reason);
scheduleForceKill(reason);
};
@@ -425,6 +425,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
@@ -495,6 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
};
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
@@ -45,12 +45,19 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
id: "claude-fable-5-1",
label: "Fable 5.1",
description: "Fable 5.1 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-fable-5",
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
aliases: ["claude-fable-5[1m]"],
label: "Fable 5",
description: "Fable 5 · Most powerful model",
description: "Fable 5 · Previous release",
minimumClaudeCodeVersion: "2.1.169",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
@@ -326,7 +333,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const runtimeMatch = trimmed.match(
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (!runtimeMatch) {
return null;
@@ -371,7 +378,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
const runtimeMatch = trimmed.match(
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
return null;
@@ -417,6 +424,14 @@ function normalizeMajorMinorClaudeModelId(
): string | null {
const family = familyValue.toLowerCase();
const suffix = hasOneMillionContext ? "[1m]" : "";
const candidate = `claude-${family}-${major}-${minor}${suffix}`;
return isClaudeManifestModelId(candidate) ? candidate : null;
const candidates = [
`claude-${family}-${major}-${minor}${suffix}`,
`claude-${family}-${major}-${minor}`,
];
for (const candidate of candidates) {
if (isClaudeManifestModelId(candidate)) {
return candidate;
}
}
return null;
}
@@ -51,6 +51,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000],
["claude-opus-4-8[1m]", 1_000_000],
@@ -160,6 +162,9 @@ describe("getClaudeModels", () => {
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
CLAUDE_DISABLED_THINKING_OPTION_ID,
);
expect(
models.get("claude-fable-5-1")?.thinkingOptions?.map((option) => option.id),
).not.toContain(CLAUDE_DISABLED_THINKING_OPTION_ID);
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
});
@@ -170,6 +175,7 @@ describe("getClaudeModels", () => {
["claude-sonnet-5[1m]", true, "high"],
["claude-sonnet-5-20260101", true, "high"],
["claude-fable-5", false, "high"],
["claude-fable-5-1", false, "high"],
["claude-haiku-4-5", false, undefined],
["openrouter/anthropic/claude-opus-4-8", false, undefined],
[null, false, undefined],
@@ -458,7 +464,7 @@ describe("Claude Opus 5 catalog", () => {
describe("Claude Fable 5 catalog", () => {
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
const fable5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-fable-5"))
.filter((model) => model.id === "claude-fable-5" || model.id === "claude-fable-5[1m]")
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
id,
aliases,
@@ -491,6 +497,24 @@ describe("Claude Fable 5 catalog", () => {
});
});
describe("Claude Fable 5.1 catalog", () => {
it("offers one Fable 5.1 entry with a 1M context window", () => {
const fable51Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-fable-5-1"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(fable51Models).toEqual([
{ id: "claude-fable-5-1", label: "Fable 5.1", contextWindowMaxTokens: 1_000_000 },
]);
});
it("resolves suffixed and dated Fable 5.1 IDs to the catalog entry", () => {
expect(findClaudeModel("claude-fable-5-1[1m]")?.id).toBe("claude-fable-5-1");
expect(findClaudeModel("claude-fable-5-1-20260901")?.id).toBe("claude-fable-5-1");
expect(findClaudeModel("claude-fable-5-1-20260901[1m]")?.id).toBe("claude-fable-5-1");
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -502,5 +526,6 @@ describe("claudeManifestModelSupportsFastMode", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
});
});
+12 -10
View File
@@ -23,10 +23,6 @@ type SupervisorLifecycleMessage =
reason?: string;
};
interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface BootstrapResult {
paseoHome: string;
logger: ReturnType<typeof createRootLogger>;
@@ -272,13 +268,19 @@ async function main() {
};
process.on("message", (message: unknown) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
(message as SupervisorHeartbeatMessage).type === "paseo:supervisor-heartbeat"
) {
if (typeof message !== "object" || message === null || !("type" in message)) {
return;
}
const type = (message as { type?: unknown }).type;
if (type === "paseo:supervisor-heartbeat") {
lastSupervisorHeartbeatAt = Date.now();
return;
}
if (type === "paseo:graceful-shutdown") {
const reason = (message as { reason?: unknown }).reason;
beginShutdown("Supervisor shutdown request", {
reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown",
});
}
});
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.7.0",
"version": "0.7.2",
"private": true,
"type": "module",
"scripts": {