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
98 changed files with 3349 additions and 968 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog # 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 ## 0.7.0 - 2026-08-31
### Changed ### 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. - **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. - **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. - **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 ## Measuring
+21 -10
View File
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
- the active gesture's starting revision - the active gesture's starting revision
- the last settled target - the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may React publishes the active panel only when the canonical target and the UI-thread position agree at
receive pointer events. Worklets never own `display` or `pointerEvents`. 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 ## Why one position
@@ -55,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 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. 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 Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
its target and revision still match the canonical command. 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: Manual gesture arbitration has two phases:
@@ -85,8 +89,14 @@ definition, no longer eligible to begin.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach - 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 Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes. has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated - `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
prop commit from resurrecting a closed overlay. 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, - Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
pointer events, and the active signal consumed by `useRetainedPanelActive`. pointer events, and the active signal consumed by `useRetainedPanelActive`.
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the - Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
@@ -98,8 +108,9 @@ definition, no longer eligible to begin.
- Retention order and render order are separate concerns. LRU metadata may change on every switch; - 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 keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android. Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- The newly active panel must be included in the same render that changes selection. Adding it from an - `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
effect creates a committed frame where every retained panel is hidden, which is a real blank screen. 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 - 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, 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. and use the retained-panel active signal to stop timers, polling, and other genuine background work.
@@ -107,5 +118,5 @@ definition, no longer eligible to begin.
## Tests ## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption, `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 rapid-command, position-settlement, and width-projection sequences through the transition model. Add
sequence there whenever ownership or ordering changes. 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", "name": "paseo",
"version": "0.7.0", "version": "0.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.7.0", "version": "0.7.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -36298,7 +36298,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.0", "@getpaseo/server": "0.7.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.0", "@getpaseo/relay": "0.7.2",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -37629,7 +37629,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.7.0", "version": "0.7.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0", "version": "0.7.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.1.0", "react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"react": "19.1.0", "react": "19.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38833,7 +38833,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8", "@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.0", "@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.0", "@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.0", "@getpaseo/relay": "0.7.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.7.0", "version": "0.7.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.7.0", "version": "0.7.2",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
+246 -63
View File
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
return page.getByTestId("changes-file-tree").filter({ visible: true }); 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> { async function readFileIfPresent(filePath: string): Promise<string | null> {
try { try {
return await readFile(filePath, "utf8"); 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 }) => { test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff(); 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 panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-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(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 }) => { 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"); const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click(); await mode.click();
await page.getByTestId("changes-diff-mode-committed").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 mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").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 expect(seeCommitted).toBeVisible();
await seeCommitted.click(); await seeCommitted.click();
await expect(mode).toContainText("Committed"); 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 ({ 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 useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts"); const deletedFileName = page.getByTestId("diff-file-1");
const deletedFileName = page.getByTestId("diff-file-1-name"); await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick(); await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); 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 first = page.getByTestId("diff-file-0-toggle");
const second = page.getByTestId("diff-file-1-toggle"); const second = page.getByTestId("diff-file-1-toggle");
const normalBackground = await first.evaluate( const canvas = page.getByTestId("git-diff-header-canvas");
(element) => getComputedStyle(element.parentElement!).backgroundColor, const normalBackground = await headerCanvasPixel(canvas, first, 10);
);
await expect
.poll(() =>
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
)
.toBe("0px");
await first.hover(); await first.hover();
const hoverBackground = await first.evaluate( await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
(element) => getComputedStyle(element).backgroundColor, const hoverBackground = await headerCanvasPixel(canvas, first, 10);
);
expect(hoverBackground).not.toBe(normalBackground);
await first.click(); await first.click();
await page.mouse.move(0, 0); await page.mouse.move(0, 0);
await expect(first).toHaveAttribute("aria-expanded", "false"); await expect(first).toHaveAttribute("aria-expanded", "false");
await expect await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
.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 first.hover(); await first.hover();
await expect await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
await second.hover(); 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 await expect
.poll(() => .poll(async () => {
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor), const bounds = await second.boundingBox();
) return bounds ? bounds.y - scrollBounds.y : Number.NaN;
.toBe(normalBackground); })
await expect .toBeCloseTo(0, 0);
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
}); });
test("changes context menus duplicate files and folders", async ({ page }) => { 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 tree = changesTree(page);
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]'); 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 // 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. // 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 // 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. // of the diff rather than a match, and mask a real ordering difference.
await expect(treeNames).toHaveCount(expected.length); 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(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 }) => { 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 useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
const renamedToggle = page const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
.getByTestId(/^diff-file-\d+-toggle$/) const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
.filter({ hasText: "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"); const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull(); expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length); 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 useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
const untrackedToggle = page const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
.getByTestId(/^diff-file-\d+-toggle$/) const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
.filter({ hasText: "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"); const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull(); expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length); 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-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1); await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
await expect(visiblePanel).toContainText("zz-deleted.ts"); "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(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1); await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit"); 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 expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); 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, { await expect(
timeout: 30_000, visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
}); ).toHaveCount(0, { timeout: 30_000 });
await expect(visiblePanel).toContainText("zz-deleted.ts"); 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 writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({ await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
timeout: 30_000, "src/use-mounted-tab-set.ts, +37, -1",
}); { 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/zz-deleted.ts")).toHaveAccessibleName(
"src/zz-deleted.ts, +0, -1",
);
}); });
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => { 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); 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 ({ test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
page, page,
}) => { }) => {
@@ -1380,6 +1483,58 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
return { id: created.workspace.id, repoPath: repo.path }; 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> { async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 }); await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
@@ -1389,11 +1544,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page); 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.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page); await openChangesPanel(page, timeout);
} }
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> { async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -1402,7 +1561,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page); await openChangesPanel(page);
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true }); 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, timeout: 30_000,
}); });
await expectExpandedMountedTabDiff(page); await expectExpandedMountedTabDiff(page);
@@ -1701,6 +1860,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
await page.mouse.up(); 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<{ async function readDiffTypographyGeometry(page: Page): Promise<{
horizontalExtent: number; horizontalExtent: number;
canvasPixels: string; canvasPixels: string;
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
} }
async function expectCommitDiffHeaderGeometry(panel: Locator): 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").boundingBox(),
panel.getByTestId("diff-file-0-header-content").boundingBox(), panel.getByTestId("git-diff-header-canvas").boundingBox(),
panel.getByTestId("diff-file-0-name").boundingBox(),
panel.getByTestId("diff-file-0-stat").boundingBox(),
]); ]);
expect(header).not.toBeNull(); expect(header).not.toBeNull();
expect(content).not.toBeNull(); expect(canvas).not.toBeNull();
expect(name).not.toBeNull();
expect(stat).not.toBeNull();
expect(header!.height).toBeCloseTo(30, 0); expect(header!.height).toBeCloseTo(30, 0);
expect(content!.x).toBeCloseTo(header!.x, 0); expect(header!.x).toBeCloseTo(canvas!.x, 0);
expect(content!.width).toBeCloseTo(header!.width, 0); expect(header!.width).toBeCloseTo(canvas!.width, 0);
expect(name!.x - header!.x).toBeCloseTo(12, 0); expect(header!.y).toBeCloseTo(canvas!.y, 0);
expect(name!.x + name!.width).toBeLessThan(stat!.x); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
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);
} }
+266 -23
View File
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
import { DIFF_POC_BASELINE } from "./diff-performance-baseline"; import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1"; 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 diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT_PER_FILE = 1_200; 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 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 CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
const POC_P95_FRAME_MS = Number( const POC_P95_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs, 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_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1; 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", () => { diffPerfDescribe("Diff canvas performance", () => {
test("opening a review stays responsive in a large diff", async ({ page }) => { 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); test.setTimeout(180_000);
const repo = await createLargeDiffRepository(); const repo = await createLargeDiffRepository();
const client = await connectSeedClient(); const client = await connectSeedClient();
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
await openChangesPanel(page); await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page); await expectBoundedCanvasSurfaces(page);
cdp = await page.context().newCDPSession(page); cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN }); await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startChromiumTrace(cdp); await startChromiumTrace(cdp);
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page); await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
const renderer = await readRendererInstrumentation(page); const renderer = await readRendererInstrumentation(page);
const measuredCycles = scrollReports.slice(1); 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( expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS, MAX_MEDIAN_FRAME_MS,
); );
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual( expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
heapUsage[0]!.usedSize * 1.1 + 1_000_000, heapUsage[0]!.usedSize * 1.1 + 1_000_000,
); );
expect(renderer.addedElements).toBe(0); expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBe(0); expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(2); expect(renderer.reactCommits).toBeLessThanOrEqual(30);
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true }); await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped"); const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
testInfo: TestInfo, testInfo: TestInfo,
name: "wrapped" | "split", name: "wrapped" | "split",
) { ) {
await expectSingleCanvasSurface(page); await expectBoundedCanvasSurfaces(page);
await startRendererInstrumentation(page); await startRendererInstrumentation(page);
const scrollReports = []; const scrollReports = [];
const heapUsage = []; const heapUsage = [];
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
} }
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000); expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
const renderer = await readRendererInstrumentation(page); const renderer = await readRendererInstrumentation(page);
expect(renderer.addedElements).toBe(0); expect(renderer.addedElements).toBeLessThanOrEqual(40);
expect(renderer.removedElements).toBe(0); expect(renderer.removedElements).toBeLessThanOrEqual(40);
expect(renderer.reactCommits).toBeLessThanOrEqual(2); expect(renderer.reactCommits).toBeLessThanOrEqual(30);
return { return {
scrollReports, scrollReports,
heapUsage, heapUsage,
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
return repo; 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> { async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ preferencesKey }) => { ({ preferencesKey }) => {
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
return performance.now() - startedAt; return performance.now() - startedAt;
} }
async function expectSingleCanvasSurface(page: Page): Promise<void> { async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(1); await expect(page.locator("canvas")).toHaveCount(2);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page const elementCount = await page
.getByTestId("git-diff-canvas-root") .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 { interface CapturedCanvasFrame {
scrollTop: number; scrollTop: number;
image: Buffer; image: Buffer;
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling( async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
page: Page, page: Page,
): Promise<void> { ): Promise<void> {
const scroller = page.getByTestId("git-diff-scroll");
const first = page.getByTestId("diff-file-0-horizontal-scroll"); const first = page.getByTestId("diff-file-0-horizontal-scroll");
const second = page.getByTestId("diff-file-1-horizontal-scroll"); const second = page.getByTestId("diff-file-1-horizontal-scroll");
const firstOffset = await setHorizontalOffset(first, 240); await setVerticalOffset(scroller, 0);
const secondOffset = await setHorizontalOffset(second, 120); 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); expect(firstOffset).not.toBe(secondOffset);
await page.getByTestId("git-diff-scroll").evaluate(async (element) => { await setVerticalOffset(scroller, 0);
element.scrollTop = element.scrollHeight - element.clientHeight; await expect(first).toBeAttached();
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 expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset); 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); 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) { async function setHorizontalOffset(locator: Locator, offset: number) {
return locator.evaluate(async (element, nextOffset) => { return locator.evaluate(async (element, nextOffset) => {
element.scrollLeft = nextOffset; element.scrollLeft = nextOffset;
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page); await openChangesPanel(page);
await expect( await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toBeVisible({ timeout: 30_000 }); ).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
// Workspace B (same directory): the SAME directory-backed content is // Workspace B (same directory): the SAME directory-backed content is
// visible even though the panel tabs belong to a different workspace. // 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 expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page); await openChangesPanel(page);
await expect( await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toBeVisible({ timeout: 30_000 }); ).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
} finally { } finally {
await seeded.cleanup(); 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); await openChangesTreePanel(page);
const changedFile = page const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]') .locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true }) .filter({ visible: true })
.first(); .first();
await expect(changedFile).toBeVisible({ timeout: 30_000 }); await expect(changedFile).toBeVisible({ timeout });
await changedFile.click(); await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({ await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000, timeout,
}); });
} }
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.7.0", "version": "0.7.2",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -162,5 +162,10 @@
"vitest": "^4.1.6", "vitest": "^4.1.6",
"wrangler": "^4.105.0", "wrangler": "^4.105.0",
"ws": "^8.20.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, assistantId,
{ {
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"), completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000, 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> { function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = { const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9), completedAt: timestamp(9),
durationMs: 8000, 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", () => { it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2); const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3); const thinking = thought("thought-1", 3);
+102 -55
View File
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
export interface StreamLayoutItem { export interface StreamLayoutItem {
item: StreamItem; item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null; aboveItem: StreamItem | null;
belowItem: StreamItem | null; belowItem: StreamItem | null;
gapBelow: number; gapBelow: number;
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
return null; return null;
} }
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { // Last layout emitted for each stream item. A row only rerenders when its layout item identity
return input.items.map((item, index) => { // changes, so an item whose render-relevant layout is unchanged must keep its previous object even
const aboveItem = getSegmentNeighbor({ // when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
strategy: input.strategy, const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
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 { function areTurnFooterHostsEqual(
item, left: TurnFooterHost | null,
index, right: TurnFooterHost | null,
items: input.items, ): boolean {
aboveItem, if (left === right) return true;
belowItem, if (!left || !right) return false;
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem), return (
assistantSpacing, left.itemId === right.itemId &&
completedFooter, left.timing === right.timing &&
toolSequence: getToolSequence({ item, aboveItem, belowItem }), left.startIndex === right.startIndex &&
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message", left.items === right.items
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message", );
isLastInToolSequence: }
isToolSequenceItem(item) &&
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)), function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
frameOrder: input.frameOrder, return (
phase: input.phase, 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.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
startedAt: tail[2]?.timestamp,
completedAt: tail[3]?.timestamp, completedAt: tail[3]?.timestamp,
durationMs: 1000, durationMs: 1000,
}); });
@@ -176,7 +175,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.runningStartedAt).toBe(null); expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp, completedAt: head[0]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -196,7 +194,6 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp, completedAt: tail[1]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
}; };
const HISTORY_START_SETTLE_FRAMES = 2; 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 { function keyExtractor(item: { id: string }): string {
return item.id; return item.id;
} }
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} }
return [...segments.historyVirtualized, ...segments.historyMounted]; return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]); }, [segments.historyMounted, segments.historyVirtualized]);
// Keep unchanged item identities intact so live updates only rerender rows const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
// 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 getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => { const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current; const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport = const hasMeasuredViewport =
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); 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", () => { it("keeps a row mounted when it moves from the live head into mounted history", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>(); const viewportRef = React.createRef<StreamViewportHandle>();
+12 -1
View File
@@ -20,6 +20,7 @@ import {
shouldAdjustScrollForVirtualRowResize, shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization"; } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy"; import { createStreamStrategy } from "./strategy";
import { import {
abandonHistoryStartPaginationRequest, abandonHistoryStartPaginationRequest,
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const { const {
segments, segments: inputSegments,
historyRowRevision,
liveHeadRowRevision, liveHeadRowRevision,
boundary, boundary,
renderers, renderers,
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled, scrollEnabled,
isMobileBreakpoint, isMobileBreakpoint,
} = props; } = 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 isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive); const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
assistant("heartbeat-2-reply", 6, "turn-3"), 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([ it.each([
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
const completed = layoutFor(completedItems, false); const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]); expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
startedAt: at(1),
completedAt: at(9), completedAt: at(9),
durationMs: 8000, 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: { function renderHistoryStreamItem(input: {
item: StreamItem; item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>; layoutItemById: Map<string, StreamLayoutItem>;
@@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: {
if (!layoutItem) { if (!layoutItem) {
return null; return null;
} }
return input.renderStreamItem(layoutItem); return (
<HistoryStreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
} }
function renderLiveHeadStreamItem(input: { function renderLiveHeadStreamItem(input: {
@@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], [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( const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => { (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = projectedToolCalls.groupsByHostId.get(item.id); const group = getToolCallGroup(item.id);
if (!group) { if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
} }
@@ -818,8 +845,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
}, },
[ [
projectedToolCalls.groupsByHostId,
expandedToolCallGroupIds, expandedToolCallGroupIds,
getToolCallGroup,
renderSingleToolCallItem, renderSingleToolCallItem,
setToolCallGroupExpanded, setToolCallGroupExpanded,
], ],
+5 -6
View File
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures"; import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider"; import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider"; import { I18nProvider } from "@/i18n/provider";
import { import {
KeyboardActionDispatcherProvider, KeyboardActionDispatcherProvider,
@@ -114,7 +114,7 @@ import {
useHosts, useHosts,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store"; import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme"; import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -627,10 +627,9 @@ function SidebarChrome({
keyboardShortcutsEnabled: boolean; keyboardShortcutsEnabled: boolean;
}) { }) {
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => const isMobileActive = useIsMobilePanelActive("agent-list");
selectIsAgentListOpen(state, { isCompact: isCompactLayout }), const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
); const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const active = visible && isOpen;
return ( return (
<SidebarModelProvider active={active}> <SidebarModelProvider active={active}>
{mounted ? <LeftSidebar active={active} /> : null} {mounted ? <LeftSidebar active={active} /> : null}
@@ -8,8 +8,9 @@ import {
NativeExplorerSidebarDock, NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar"; } from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures"; import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
): CompactExplorerSidebarHostModel | null { ): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen); const isExplorerActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? ""); const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
const resolvedModel = useMemo( const resolvedModel = useMemo(
() => () =>
resolveCompactExplorerSidebarHostModel({ resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null, previous: isExplorerActive ? retainedModelRef.current : null,
selection, selection,
workspace, workspace,
isGit: checkoutQuery.data?.isGit ?? false, isGit: checkoutQuery.data?.isGit ?? false,
}), }),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], [checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
); );
useEffect(() => { useEffect(() => {
if (!selection) { if (!selection) {
retainedModelRef.current = null; retainedModelRef.current = null;
if (enabled && isExplorerOpen) { if (enabled && isExplorerActive) {
showMobileAgent(); showMobileAgent();
} }
return; return;
} }
if (!isExplorerOpen) { if (!isExplorerActive) {
retainedModelRef.current = null; retainedModelRef.current = null;
return; return;
} }
if (resolvedModel) { if (resolvedModel) {
retainedModelRef.current = resolvedModel; retainedModelRef.current = resolvedModel;
} }
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); }, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
} }
interface CompactExplorerSidebarHostProps { interface CompactExplorerSidebarHostProps {
@@ -14,6 +14,7 @@ import {
type ExplorerTab, type ExplorerTab,
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
}: ExplorerSidebarProps) { }: ExplorerSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen); const isActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId, serverId,
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
(reason: string) => { (reason: string) => {
logExplorerSidebar("handleClose", { logExplorerSidebar("handleClose", {
reason, reason,
isOpen, isOpen: isActive,
}); });
showMobileAgent(); showMobileAgent();
}, },
[isOpen, showMobileAgent], [isActive, showMobileAgent],
); );
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
); );
return ( return (
<RetainedPanelActivity active={isOpen}> <RetainedPanelActivity active={isActive}>
<MobilePanelOverlay <MobilePanelOverlay
panel="file-explorer" panel="file-explorer"
closeGesture={closeGesture} closeGesture={closeGesture}
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
workspaceId={workspaceId} workspaceId={workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isOpen={isOpen} isOpen={isActive}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</MobilePanelOverlay> </MobilePanelOverlay>
+1 -9
View File
@@ -1,5 +1,6 @@
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { formatDiffCount } from "@/git/file-header-presentation";
interface DiffStatProps { interface DiffStatProps {
additions: number; additions: number;
@@ -7,15 +8,6 @@ interface DiffStatProps {
testID?: string; 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) { export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
return ( return (
<View style={styles.row} testID={testID}> <View style={styles.row} testID={testID}>
+4 -3
View File
@@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
import { import {
buildOpenProjectRoute, buildOpenProjectRoute,
buildNewWorkspaceRoute, buildNewWorkspaceRoute,
@@ -121,6 +120,7 @@ interface SidebarLabels {
} }
interface MobileSidebarProps extends SidebarSharedProps { interface MobileSidebarProps extends SidebarSharedProps {
active: boolean;
insetsTop: number; insetsTop: number;
insetsBottom: number; insetsBottom: number;
closeSidebar: () => void; closeSidebar: () => void;
@@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
<RetainedPanelActivity active={active}> <RetainedPanelActivity active={active}>
<MobileSidebar <MobileSidebar
{...sharedProps} {...sharedProps}
active={active}
insetsTop={insets.top} insetsTop={insets.top}
insetsBottom={insets.bottom} insetsBottom={insets.bottom}
closeSidebar={showMobileAgent} closeSidebar={showMobileAgent}
@@ -604,6 +605,7 @@ function SidebarFooter({
} }
function MobileSidebar({ function MobileSidebar({
active,
theme, theme,
workspaceGroups, workspaceGroups,
projectIconTargets, projectIconTargets,
@@ -638,7 +640,6 @@ function MobileSidebar({
const isSessionsActive = pathname.includes("/sessions"); const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules"); const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(); const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
const handleViewMore = useCallback(() => { const handleViewMore = useCallback(() => {
closeSidebar(); closeSidebar();
@@ -737,7 +738,7 @@ function MobileSidebar({
onWorkspacePress={handleWorkspacePress} onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject} onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef} parentGestureRef={closeGestureRef}
dragGestureHostPresented={dragGestureHostPresented} dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement} listHeaderComponent={workspacesSectionHeaderElement}
/> />
)} )}
+13 -10
View File
@@ -578,7 +578,7 @@ export const UserMessage = memo(function UserMessage({
interface AssistantTurnFooterProps { interface AssistantTurnFooterProps {
getContent: () => string; getContent: () => string;
completedAt?: Date; completedAt?: Date;
durationMs?: number; durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise<void> | void; 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. * 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) * Shows the turn duration and swaps to the end timestamp when both are known.
* or tap (native). The hidden sizer keeps the label width stable while the * A turn without a visible start shows its end timestamp directly.
* visible text swaps.
*/ */
export const AssistantTurnFooter = memo(function AssistantTurnFooter({ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent, getContent,
@@ -640,7 +639,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
}, []); }, []);
const durationLabel = useMemo( const durationLabel = useMemo(
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""), () =>
durationMs !== undefined && durationMs !== null
? `Worked for ${formatDuration(durationMs)}`
: "",
[durationMs], [durationMs],
); );
const timestampLabel = useMemo( const timestampLabel = useMemo(
@@ -648,7 +650,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
[completedAt], [completedAt],
); );
const canSwap = Boolean(timestampLabel); const primaryLabel = durationLabel || timestampLabel;
const canSwap = Boolean(durationLabel && timestampLabel);
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal); const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
const handleHoverIn = useCallback(() => setHovered(true), []); const handleHoverIn = useCallback(() => setHovered(true), []);
@@ -679,22 +682,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
containerStyle={assistantTurnFooterStylesheet.copyButton} containerStyle={assistantTurnFooterStylesheet.copyButton}
/> />
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null} {canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? ( {primaryLabel ? (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
onHoverIn={handleHoverIn} onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut} onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined} accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel} accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
> >
<View style={assistantTurnFooterStylesheet.labelWrapper}> <View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the {/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */} container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden> <Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel} {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
</Text> </Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}> <Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : durationLabel} {showTimestamp ? timestampLabel : primaryLabel}
</Text> </Text>
</View> </View>
</Pressable> </Pressable>
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
listHeaderComponent?: ReactElement | null; listHeaderComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean; dragGestureHostActive?: boolean;
} }
interface ProjectHeaderRowProps { interface ProjectHeaderRowProps {
@@ -1540,7 +1540,7 @@ function ProjectBlock({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
useNestable, useNestable,
dragGestureHostPresented, dragGestureHostActive,
creatingWorkspaceIds, creatingWorkspaceIds,
activeWorkspaceSelection, activeWorkspaceSelection,
hostBadgeByServerId, hostBadgeByServerId,
@@ -1565,7 +1565,7 @@ function ProjectBlock({
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean; useNestable: boolean;
dragGestureHostPresented?: boolean; dragGestureHostActive?: boolean;
creatingWorkspaceIds: ReadonlySet<string>; creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null; activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>; hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -1745,7 +1745,7 @@ function ProjectBlock({
useDragHandle useDragHandle
nestable={useNestable} nestable={useNestable}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented} gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer} containerStyle={styles.workspaceListContainer}
/> />
{canToggleWorkspaces ? ( {canToggleWorkspaces ? (
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.isDragging === next.isDragging && previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps && previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable && previous.useNestable === next.useNestable &&
previous.dragGestureHostPresented === next.dragGestureHostPresented && previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds && previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
areProjectBlockSelectionsEqual(previous, next) areProjectBlockSelectionsEqual(previous, next)
); );
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
listFooterComponent, listFooterComponent,
listHeaderComponent, listHeaderComponent,
parentGestureRef, parentGestureRef,
dragGestureHostPresented, dragGestureHostActive,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const pathname = usePathname(); const pathname = usePathname();
const hosts = useHosts(); const hosts = useHosts();
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
listHeaderComponent={listHeaderComponent} listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty} sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented} dragGestureHostActive={dragGestureHostActive}
/> />
) : ( ) : (
<ProjectModeList <ProjectModeList
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
sidebarFilterEmpty={sidebarFilterEmpty} sidebarFilterEmpty={sidebarFilterEmpty}
hasActiveProjectFilter={hasActiveProjectFilter} hasActiveProjectFilter={hasActiveProjectFilter}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented} dragGestureHostActive={dragGestureHostActive}
pathname={pathname} pathname={pathname}
hostBadgeByServerId={hostBadgeByServerId} hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId} supportsMultiplicityByServerId={supportsMultiplicityByServerId}
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
listHeaderComponent, listHeaderComponent,
sidebarFilterEmpty, sidebarFilterEmpty,
parentGestureRef, parentGestureRef,
dragGestureHostPresented, dragGestureHostActive,
}: { }: {
workspaceGroups: SidebarWorkspaceGroup[]; workspaceGroups: SidebarWorkspaceGroup[];
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
listHeaderComponent?: ReactElement | null; listHeaderComponent?: ReactElement | null;
sidebarFilterEmpty: boolean; sidebarFilterEmpty: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean; dragGestureHostActive?: boolean;
}) { }) {
const showShortcutBadges = useShowShortcutBadges(); const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo( const pinnedWorkspaces = useMemo(
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
listHeaderComponent={listHeaderComponent} listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty} sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented} dragGestureHostActive={dragGestureHostActive}
/> />
); );
} }
@@ -2072,7 +2072,7 @@ function ProjectModeList({
sidebarFilterEmpty, sidebarFilterEmpty,
hasActiveProjectFilter, hasActiveProjectFilter,
parentGestureRef, parentGestureRef,
dragGestureHostPresented, dragGestureHostActive,
pathname, pathname,
hostBadgeByServerId, hostBadgeByServerId,
supportsMultiplicityByServerId, supportsMultiplicityByServerId,
@@ -2289,7 +2289,7 @@ function ProjectModeList({
isDragging={dragState.isDragging} isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps} dragHandleProps={dragState.dragHandleProps}
useNestable={platformIsNative} useNestable={platformIsNative}
dragGestureHostPresented={dragGestureHostPresented} dragGestureHostActive={dragGestureHostActive}
creatingWorkspaceIds={creatingWorkspaceIds} creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection} activeWorkspaceSelection={activeWorkspaceSelection}
hostBadgeByServerId={hostBadgeByServerId} hostBadgeByServerId={hostBadgeByServerId}
@@ -2311,7 +2311,7 @@ function ProjectModeList({
onWorkspacePress, onWorkspacePress,
onToggleProjectCollapsed, onToggleProjectCollapsed,
parentGestureRef, parentGestureRef,
dragGestureHostPresented, dragGestureHostActive,
projectIconByProjectViewKey, projectIconByProjectViewKey,
selectionEnabled, selectionEnabled,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
@@ -2388,7 +2388,7 @@ function ProjectModeList({
useDragHandle useDragHandle
nestable={platformIsNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented} gestureHostPresented={dragGestureHostActive}
containerStyle={styles.projectListContainer} containerStyle={styles.projectListContainer}
/> />
); );
@@ -2411,7 +2411,7 @@ function ProjectModeList({
useDragHandle useDragHandle
nestable={platformIsNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented} gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer} containerStyle={styles.workspaceListContainer}
/> />
{canTogglePinnedChats ? ( {canTogglePinnedChats ? (
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
/** Swaps the group list for the label filter's empty state. Never the header above it. */ /** Swaps the group list for the label filter's empty state. Never the header above it. */
sidebarFilterEmpty?: boolean; sidebarFilterEmpty?: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean; dragGestureHostActive?: boolean;
} }
export function SidebarStatusWorkspaceList({ export function SidebarStatusWorkspaceList({
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
listHeaderComponent, listHeaderComponent,
sidebarFilterEmpty = false, sidebarFilterEmpty = false,
parentGestureRef, parentGestureRef,
dragGestureHostPresented, dragGestureHostActive,
}: StatusWorkspaceListProps) { }: StatusWorkspaceListProps) {
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys, (state) => state.collapsedWorkspaceGroupKeys,
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
useDragHandle useDragHandle
nestable={platformIsNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented} gestureHostPresented={dragGestureHostActive}
/> />
{canTogglePinnedWorkspaces ? ( {canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow <SidebarGroupToggleRow
@@ -131,6 +131,46 @@ describe("EditingTextInputNative", () => {
expect(document.activeElement).toBe(container?.querySelector("input")); 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", () => { it("updates textRef and text when replaceText receives non-empty text", () => {
const handleRef = createRef<EditingTextInputHandle>(); const handleRef = createRef<EditingTextInputHandle>();
@@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
const inputRef = useRef<NativeInput | null>(null); const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue); const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current); const textRef = useRef(initialTextRef.current);
const restoreFocusAfterResetRef = useRef(false); // Clearing swaps the native input for a fresh instance (see replaceText).
const [nativeInputRevision, setNativeInputRevision] = useState(0); // 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) => { const assignInputRef = useCallback((input: NativeInput | null) => {
inputRef.current = input; inputRef.current = input;
if (!input || !restoreFocusAfterResetRef.current) return; if (input) isAwaitingReplacementRef.current = false;
restoreFocusAfterResetRef.current = false; }, []);
input.focus();
const setReplacementFocus = useCallback((autoFocus: boolean) => {
setReplacement((current) => ({ ...current, autoFocus }));
}, []); }, []);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(), focus: () => {
blur: () => inputRef.current?.blur(), if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false, isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current, getText: () => textRef.current,
replaceText: (nextText, selection) => { replaceText: (nextText, selection) => {
textRef.current = nextText; textRef.current = nextText;
if (nextText === "") { if (nextText === "") {
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false; const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) { if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection); inputRef.current.replaceText(nextText, selection);
} else { } else {
inputRef.current?.clear?.(); inputRef.current?.clear?.();
} }
setNativeInputRevision((revision) => revision + 1); isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
return; return;
} }
if (inputRef.current?.replaceText) { if (inputRef.current?.replaceText) {
@@ -93,11 +116,14 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
[onPasteError, onPasteImages], [onPasteError, onPasteImages],
); );
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
if (onPasteImages || onPasteError) { if (onPasteImages || onPasteError) {
return ( return (
<PasteInput <PasteInput
{...props} {...props}
key={nativeInputRevision} autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<PasteTextInputInstance>} ref={assignInputRef as React.Ref<PasteTextInputInstance>}
defaultValue={textRef.current} defaultValue={textRef.current}
onChangeText={handleChangeText} onChangeText={handleChangeText}
@@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return ( return (
<BottomSheetTextInput <BottomSheetTextInput
{...props} {...props}
key={nativeInputRevision} autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as unknown as React.Ref<never>} ref={assignInputRef as unknown as React.Ref<never>}
defaultValue={textRef.current} defaultValue={textRef.current}
onChangeText={handleChangeText} onChangeText={handleChangeText}
@@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return ( return (
<TextInput <TextInput
{...props} {...props}
key={nativeInputRevision} autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<TextInput>} ref={assignInputRef as React.Ref<TextInput>}
defaultValue={textRef.current} defaultValue={textRef.current}
onChangeText={handleChangeText} onChangeText={handleChangeText}
@@ -9,6 +9,8 @@ interface DocumentFileHeaderProps {
mode: DiffDocumentProps["mode"]; mode: DiffDocumentProps["mode"];
onToggleFile: (path: string) => void; onToggleFile: (path: string) => void;
onSelectPath: (path: string) => void; onSelectPath: (path: string) => void;
canvasRendered?: boolean;
onActiveChange?: (active: boolean) => void;
} }
export const DocumentFileHeader = memo(function DocumentFileHeader({ export const DocumentFileHeader = memo(function DocumentFileHeader({
@@ -17,6 +19,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
mode, mode,
onToggleFile, onToggleFile,
onSelectPath, onSelectPath,
canvasRendered = false,
onActiveChange,
}: DocumentFileHeaderProps) { }: DocumentFileHeaderProps) {
const activate = useCallback( const activate = useCallback(
(path: string) => { (path: string) => {
@@ -47,6 +51,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
onDuplicate={working?.onDuplicate} onDuplicate={working?.onDuplicate}
onRevert={working?.onRevert} onRevert={working?.onRevert}
testID={`diff-file-${file.fileIndex}`} testID={`diff-file-${file.fileIndex}`}
canvasRendered={canvasRendered}
onActiveChange={onActiveChange}
/> />
); );
}, documentFileHeaderPropsEqual); }, documentFileHeaderPropsEqual);
@@ -84,6 +90,8 @@ function documentFileHeaderIdentityMatches(
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) || (previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
previous.onToggleFile !== next.onToggleFile || previous.onToggleFile !== next.onToggleFile ||
previous.onSelectPath !== next.onSelectPath || previous.onSelectPath !== next.onSelectPath ||
previous.canvasRendered !== next.canvasRendered ||
previous.onActiveChange !== next.onActiveChange ||
previous.mode.kind !== next.mode.kind 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", deletionBackground: "#100",
emptyBackground: "#111", emptyBackground: "#111",
selection: "blue", selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {}, syntax: {},
}, },
labels: { binary: "Binary", tooLarge: "Too large" }, 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 { RenderProfile } from "@/utils/render-profiler";
import { createDiffPalette, retainDiffPalette } from "./palette"; import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface"; import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types"; import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
export type { DiffDocumentProps, WorkingDiffMode } from "./types"; export type { DiffDocumentProps, WorkingDiffMode } from "./types";
type ThemedDiffDocumentProps = DiffDocumentProps & { type ThemedDiffDocumentProps = DiffDocumentProps & {
palette: DiffPalette; palette: DiffPalette;
headerTypography: DiffHeaderTypography;
}; };
const EMPTY_PATHS: string[] = []; const EMPTY_PATHS: string[] = [];
@@ -45,6 +46,11 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({ const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
palette: createDiffPalette(theme), palette: createDiffPalette(theme),
headerTypography: {
family: theme.fontFamily.ui,
size: theme.fontSize.base,
statSize: theme.fontSize.sm,
},
})); }));
export function DiffDocument(props: DiffDocumentProps) { export function DiffDocument(props: DiffDocumentProps) {
@@ -78,6 +78,11 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
deletionBackground: "#100", deletionBackground: "#100",
emptyBackground: "#111", emptyBackground: "#111",
selection: "blue", selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {}, syntax: {},
}, },
labels: { binary: "Binary", tooLarge: "Too large" }, labels: { binary: "Binary", tooLarge: "Too large" },
@@ -58,6 +58,11 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
deletionBackground: "#100", deletionBackground: "#100",
emptyBackground: "#111", emptyBackground: "#111",
selection: "blue", selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {}, syntax: {},
}, },
labels: { binary: "Binary", tooLarge: "Too large" }, 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).toBe(464);
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224); 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 { function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
return { return {
measure, measure,
+351 -92
View File
@@ -37,9 +37,33 @@ interface CellSource {
sourceIdentity: DiffSourceIdentity; 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 { export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
const rows: DiffRow[] = []; const rows: DiffRow[] = [];
const files: DiffFileSection[] = []; const files: DiffFileSection[] = [];
const reusableModels = indexReusableModels(input.reuseFrom);
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
let documentTop = 0; let documentTop = 0;
for (const [fileIndex, file] of input.files.entries()) { 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); const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
let maximumHorizontalOverflow = 0; let maximumHorizontalOverflow = 0;
const reusableModel = input.reuseFrom?.find((candidate) => const reusable = findReusableFileRows({
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed), indexes: reusableModels,
); buildInput: input,
const reusableFile = reusableModel?.files.find( sourceFile: file,
(candidate) => candidate.file === file && !candidate.isCollapsed, fileTop,
); bodyTop,
});
const reusableModel = reusable?.model;
const reusableFile = reusable?.file;
if (!isCollapsed && reusableFile) { if (!isCollapsed) {
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd); const result =
for (const reusedRow of reusedRows) { reusableModel && reusableFile
if (reusedRow.kind === "line") { ? appendReusableFileRows({
const reviewHeight = reviewHeightForCells(reusedRow.cells, input); rows,
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,
input, input,
model: reusableModel,
file: reusableFile,
fileIndex,
fileTop,
bodyTop,
reviewGeometryKey: currentReviewGeometryKey,
})
: appendNewFileRows({
rows,
input,
file,
fileIndex,
fileTop,
bodyTop,
gutterWidth,
}); });
maximumHorizontalOverflow = Math.max( documentTop = result.bottom;
maximumHorizontalOverflow, maximumHorizontalOverflow = result.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;
} }
files.push({ files.push({
@@ -170,9 +138,193 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
layout: input.layout, layout: input.layout,
wrapLines: input.wrapLines, wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth, 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 { export function expandedBodyBorderTop(file: DiffFileSection): number | null {
"worklet"; "worklet";
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT; return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
@@ -183,7 +335,7 @@ export function retainReusableModels(
next: DiffDocumentModel, next: DiffDocumentModel,
): DiffDocumentModel[] { ): DiffDocumentModel[] {
const fullest = [...(previous ?? []), next].reduce((best, candidate) => 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]; return fullest === next ? [next] : [fullest, next];
} }
@@ -191,13 +343,14 @@ export function retainReusableModels(
function lineSources( function lineSources(
file: BuildDiffDocumentModelInput["files"][number], file: BuildDiffDocumentModelInput["files"][number],
layout: "unified" | "split", layout: "unified" | "split",
includeTokens: boolean,
): Array<[CellSource] | [CellSource | null, CellSource | null]> { ): Array<[CellSource] | [CellSource | null, CellSource | null]> {
if (layout === "split") { if (layout === "split") {
return buildSplitDiffRows(file).map((row) => { return buildSplitDiffRows(file).map((row) => {
if (row.kind === "header") { if (row.kind === "header") {
return [headerSource(row.content, row.hunkIndex, row.lineIndex)]; 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) => [ return buildUnifiedDiffLines(file).map((entry) => [
@@ -206,7 +359,7 @@ function lineSources(
content: entry.line.content, content: entry.line.content,
lineNumber: entry.lineNumber, lineNumber: entry.lineNumber,
reviewTarget: entry.reviewTarget, reviewTarget: entry.reviewTarget,
tokenText: compactHighlightTokens(entry.line.tokens ?? []), tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
sourceIdentity: { sourceIdentity: {
hunkIndex: entry.hunkIndex, hunkIndex: entry.hunkIndex,
lineIndex: entry.lineIndex, 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; if (!line) return null;
return { return {
type: line.type, type: line.type,
content: line.content, content: line.content,
lineNumber: line.lineNumber, lineNumber: line.lineNumber,
reviewTarget: line.reviewTarget, reviewTarget: line.reviewTarget,
tokenText: compactHighlightTokens(line.tokens ?? []), tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
sourceIdentity: { sourceIdentity: {
hunkIndex: line.hunkIndex, hunkIndex: line.hunkIndex,
lineIndex: line.lineIndex, 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: { function measureCell(input: {
source: CellSource; source: CellSource;
availableWidth: number; availableWidth: number;
@@ -107,6 +107,11 @@ function build(layout: "unified" | "split") {
deletionBackground: "#100", deletionBackground: "#100",
emptyBackground: "#111", emptyBackground: "#111",
selection: "blue", selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {}, syntax: {},
}, },
labels: { binary: "Binary", tooLarge: "Too large" }, labels: { binary: "Binary", tooLarge: "Too large" },
@@ -5,7 +5,6 @@ import {
nativeCanvasSlabsForViewport, nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket, nativeCanvasWindowBucket,
nativeCanvasWindowTop, nativeCanvasWindowTop,
nativeStickyHeaderIndices,
} from "./native-slabs"; } from "./native-slabs";
import type { DiffDocumentModel } from "./types"; import type { DiffDocumentModel } from "./types";
@@ -34,15 +33,11 @@ function modelWithBodies(
layout: "unified", layout: "unified",
wrapLines: false, wrapLines: false,
viewportWidth: 400, viewportWidth: 400,
reviewGeometryKey: "",
}; };
} }
describe("native canvas slabs", () => { 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", () => { it("cuts large file bodies into fixed document-positioned slabs", () => {
const slabs = buildNativeCanvasSlabs( const slabs = buildNativeCanvasSlabs(
modelWithBodies([ modelWithBodies([
@@ -58,9 +53,11 @@ describe("native canvas slabs", () => {
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([ expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
{ path: "small.ts", top: 30, height: 200 }, { path: "small.ts", top: 30, height: 200 },
{ path: "large.ts", top: 260, height: 1200 }, { path: "large.ts", top: 260, height: 768 },
{ path: "large.ts", top: 1460, height: 1200 }, { path: "large.ts", top: 1028, height: 768 },
{ path: "large.ts", top: 2660, height: 800 }, { 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([ expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
30, 1230, 2430, 798, 1566, 2334,
]); ]);
expect(nativeCanvasWindowBucket(899, 600)).toBe(2); expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
expect(nativeCanvasWindowTop(2, 600)).toBe(600); expect(nativeCanvasWindowTop(2, 600)).toBe(600);
@@ -8,15 +8,11 @@ export interface NativeCanvasSlab {
height: number; height: number;
} }
export function nativeStickyHeaderIndices(fileCount: number): number[] {
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
}
export function buildNativeCanvasSlabs( export function buildNativeCanvasSlabs(
model: DiffDocumentModel, model: DiffDocumentModel,
viewportHeight: number, viewportHeight: number,
): NativeCanvasSlab[] { ): NativeCanvasSlab[] {
const slabHeight = Math.max(768, viewportHeight * 2); const slabHeight = Math.max(768, viewportHeight);
return model.files.flatMap((file) => { return model.files.flatMap((file) => {
const slabs: NativeCanvasSlab[] = []; const slabs: NativeCanvasSlab[] = [];
let slabIndex = 0; let slabIndex = 0;
@@ -39,8 +35,8 @@ export function nativeCanvasSlabsForViewport(
scrollTop: number, scrollTop: number,
viewportHeight: number, viewportHeight: number,
): NativeCanvasSlab[] { ): NativeCanvasSlab[] {
const top = Math.max(0, scrollTop - viewportHeight); const top = Math.max(0, scrollTop - viewportHeight / 2);
const bottom = scrollTop + viewportHeight * 2; const bottom = scrollTop + viewportHeight * 1.5;
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top); return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
} }
@@ -1,16 +1,37 @@
import { import {
ClipOp, ClipOp,
PaintStyle,
Skia, Skia,
StrokeCap,
StrokeJoin,
type SkCanvas, type SkCanvas,
type SkPaint, type SkPaint,
type SkPicture, type SkPicture,
} from "@shopify/react-native-skia"; } 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 { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
import { nativeTextRuns } from "./native-text-runs"; import { nativeTextRuns } from "./native-text-runs";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets"; import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import { codeLineNumberTone } from "./palette"; import { codeLineNumberTone } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint"; 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 { import type {
DiffCell, DiffCell,
DiffDocumentModel, DiffDocumentModel,
@@ -31,6 +52,10 @@ export interface NativePaints {
additionBackground: SkPaint; additionBackground: SkPaint;
deletionBackground: SkPaint; deletionBackground: SkPaint;
headerSurface: SkPaint; headerSurface: SkPaint;
headerBorder: SkPaint;
statusSuccess: SkPaint;
statusDanger: SkPaint;
statusWarning: SkPaint;
emptyBackground: SkPaint; emptyBackground: SkPaint;
text: Record<string, SkPaint>; text: Record<string, SkPaint>;
} }
@@ -46,6 +71,10 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
additionBackground: paint(palette.additionBackground), additionBackground: paint(palette.additionBackground),
deletionBackground: paint(palette.deletionBackground), deletionBackground: paint(palette.deletionBackground),
headerSurface: paint(palette.headerSurface), headerSurface: paint(palette.headerSurface),
headerBorder: paint(palette.headerBorder),
statusSuccess: paint(palette.statusSuccess),
statusDanger: paint(palette.statusDanger),
statusWarning: paint(palette.statusWarning),
emptyBackground: paint(palette.emptyBackground), emptyBackground: paint(palette.emptyBackground),
text: Object.fromEntries( 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 { function paint(color: string): SkPaint {
const result = Skia.Paint(); const result = Skia.Paint();
result.setColor(Skia.Color(color)); result.setColor(Skia.Color(color));
@@ -1,8 +1,94 @@
import { describe, expect, it } from "vitest"; 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"; import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
describe("web diff text shaping", () => { 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", () => { it("paints syntax colors by clipping the complete measured shaped fragment", () => {
const paintedText: string[] = []; const paintedText: string[] = [];
const context = { const context = {
@@ -314,6 +400,7 @@ function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
layout, layout,
wrapLines: false, wrapLines: false,
viewportWidth: 200, viewportWidth: 200,
reviewGeometryKey: "",
}; };
} }
@@ -356,6 +443,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg", deletionBackground: "red-bg",
emptyBackground: "empty", emptyBackground: "empty",
selection: "selection", selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: { first: "red", second: "blue" }, syntax: { first: "red", second: "blue" },
}; };
@@ -427,6 +519,7 @@ const model: DiffDocumentModel = {
layout: "unified", layout: "unified",
wrapLines: false, wrapLines: false,
viewportWidth: 200, viewportWidth: 200,
reviewGeometryKey: "",
}; };
const modelWithReview: DiffDocumentModel = { const modelWithReview: DiffDocumentModel = {
@@ -6,10 +6,26 @@ import {
visibleRowRange, visibleRowRange,
} from "./model"; } from "./model";
import { codeLineNumberTone, codeTextColor } from "./palette"; 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 { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type { import type {
DiffCell, DiffCell,
DiffDocumentModel, DiffDocumentModel,
DiffFileSection,
DiffHeaderTypography,
DiffLineRow, DiffLineRow,
DiffPalette, DiffPalette,
DiffSelection, DiffSelection,
@@ -86,6 +102,167 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
context.restore(); 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( function paintLine(
input: PaintWebViewportInput & { input: PaintWebViewportInput & {
row: DiffLineRow; row: DiffLineRow;
@@ -59,6 +59,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg", deletionBackground: "red-bg",
emptyBackground: "empty", emptyBackground: "empty",
selection: "selection", selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: {}, syntax: {},
}; };
@@ -15,6 +15,11 @@ export function createDiffPalette(theme: Theme): DiffPalette {
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1), deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
emptyBackground: theme.colors.surface0, emptyBackground: theme.colors.surface0,
selection: theme.colors.terminal.blue, 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, syntax: theme.colors.syntax,
}; };
} }
@@ -32,6 +37,11 @@ export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): Dif
previous.deletionBackground !== next.deletionBackground || previous.deletionBackground !== next.deletionBackground ||
previous.emptyBackground !== next.emptyBackground || previous.emptyBackground !== next.emptyBackground ||
previous.selection !== next.selection || 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) !sameColorMap(previous.syntax, next.syntax)
) { ) {
return next; return next;
@@ -23,6 +23,11 @@ import { InlineReviewThread } from "@/review";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DocumentFileHeader } from "./document-file-header"; import { DocumentFileHeader } from "./document-file-header";
import {
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffBodyPoint } from "./native-hit-testing"; import { hitTestDiffBodyPoint } from "./native-hit-testing";
import { retainDiffViewport } from "./viewport"; import { retainDiffViewport } from "./viewport";
import { HorizontalScroll } from "./horizontal-scroll.native"; import { HorizontalScroll } from "./horizontal-scroll.native";
@@ -42,16 +47,22 @@ import {
nativeCanvasSlabsForViewport, nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket, nativeCanvasWindowBucket,
nativeCanvasWindowTop, nativeCanvasWindowTop,
nativeStickyHeaderIndices,
type NativeCanvasSlab, type NativeCanvasSlab,
} from "./native-slabs"; } from "./native-slabs";
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native"; import {
createNativePaints,
recordNativeHeaderPicture,
recordNativeSlabPictures,
type NativePaints,
} from "./paint.native";
import { import {
createNativeTextLayoutStore, createNativeTextLayoutStore,
createNativeHeaderTextLayout,
createNativeTextMeasurer, createNativeTextMeasurer,
disposeNativeTextLayout, disposeNativeTextLayout,
prepareNativeTextLayout, prepareNativeTextLayout,
type NativeTextLayout, type NativeTextLayout,
type NativeHeaderTextLayout,
} from "./text.native"; } from "./text.native";
import type { import type {
DiffDocumentModel, DiffDocumentModel,
@@ -68,6 +79,7 @@ const CODE_LEFT_PADDING = 8;
export function DiffSurface(props: DiffSurfaceProps) { export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [viewport, setViewport] = useState({ width: 0, height: 0 }); const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [fileWindowTop, setFileWindowTop] = useState(0);
const scrollRef = useRef<ScrollView>(null); const scrollRef = useRef<ScrollView>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null); const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{ const reusableModelRef = useRef<{
@@ -126,6 +138,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"), binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"), tooLarge: t("workspace.git.diff.tooLarge"),
}, },
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
reuseFrom, reuseFrom,
}); });
reusableModelRef.current = { reusableModelRef.current = {
@@ -135,6 +148,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
return next; return next;
}, [ }, [
measurement, measurement,
fileWindowTop,
props.collapsedFilePaths, props.collapsedFilePaths,
props.displayPreferences.layout, props.displayPreferences.layout,
props.displayPreferences.wrapLines, props.displayPreferences.wrapLines,
@@ -144,6 +158,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
t, t,
typography, typography,
viewport.width, viewport.width,
viewport.height,
]); ]);
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]); const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
const textLayoutStore = useMemo( const textLayoutStore = useMemo(
@@ -161,6 +176,38 @@ export function DiffSurface(props: DiffSurfaceProps) {
[model, textLayoutStore], [model, textLayoutStore],
); );
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [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) => { const scrollHandler = useAnimatedScrollHandler((event) => {
scrollTop.value = event.contentOffset.y; scrollTop.value = event.contentOffset.y;
@@ -206,12 +253,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
} }
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]); }, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentStyle = useMemo( const contentStyle = useMemo(
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }), () => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
[viewport.height], [model.height, viewport.height],
);
const stickyHeaderIndices = useMemo(
() => nativeStickyHeaderIndices(model.files.length),
[model.files.length],
); );
return ( return (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}> <View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
@@ -222,7 +265,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
onScroll={scrollHandler} onScroll={scrollHandler}
scrollEventThrottle={16} scrollEventThrottle={16}
showsVerticalScrollIndicator showsVerticalScrollIndicator
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll" testID="git-diff-scroll"
> >
<NativeCanvasSlabLayer <NativeCanvasSlabLayer
@@ -233,23 +275,31 @@ export function DiffSurface(props: DiffSurfaceProps) {
paints={paints} paints={paints}
horizontalOffsets={horizontalOffsets} horizontalOffsets={horizontalOffsets}
/> />
{model.files.flatMap((file) => [ {interactionFiles.map((file) => (
<NativeStickyFileHeader <NativeCanvasFileHeader
key={`${file.path}:header`} key={`${file.path}:header`}
file={file} file={file}
viewportWidth={model.viewportWidth}
scrollTop={scrollTop}
textLayout={headerTextLayout}
paints={paints}
headerSurface={props.palette.headerSurface}
headerActiveSurface={props.palette.headerActiveSurface}
selectedPath={props.selectedPath} selectedPath={props.selectedPath}
mode={props.mode} mode={props.mode}
onToggleFile={props.onToggleFile} onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath} onSelectPath={props.onSelectPath}
/>, />
))}
{interactionFiles.map((file) => (
<NativeFileBody <NativeFileBody
key={`${file.path}:body`} key={`${file.path}:body`}
file={file} file={file}
model={model} model={model}
mode={props.mode} mode={props.mode}
horizontalOffsets={horizontalOffsets} horizontalOffsets={horizontalOffsets}
/>, />
])} ))}
<NativeReviewOverlays model={model} mode={props.mode} /> <NativeReviewOverlays model={model} mode={props.mode} />
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} /> <Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
</AnimatedScrollView> </AnimatedScrollView>
@@ -306,29 +356,69 @@ function NativeCanvasSlabLayer({
)); ));
} }
function NativeStickyFileHeader({ function NativeCanvasFileHeader({
file, file,
viewportWidth,
scrollTop,
textLayout,
paints,
headerSurface,
headerActiveSurface,
selectedPath, selectedPath,
mode, mode,
onToggleFile, onToggleFile,
onSelectPath, onSelectPath,
}: { }: {
file: DiffFileSection; file: DiffFileSection;
viewportWidth: number;
scrollTop: SharedValue<number>;
textLayout: NativeHeaderTextLayout;
paints: NativePaints;
headerSurface: string;
headerActiveSurface: string;
selectedPath: DiffSurfaceProps["selectedPath"]; selectedPath: DiffSurfaceProps["selectedPath"];
mode: DiffSurfaceProps["mode"]; mode: DiffSurfaceProps["mode"];
onToggleFile: DiffSurfaceProps["onToggleFile"]; onToggleFile: DiffSurfaceProps["onToggleFile"];
onSelectPath: DiffSurfaceProps["onSelectPath"]; 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 ( return (
<View style={styles.header}> <Animated.View style={[style, stickyStyle]}>
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Picture picture={picture} />
</Canvas>
<DocumentFileHeader <DocumentFileHeader
file={file} file={file}
selectedPath={selectedPath} selectedPath={selectedPath}
mode={mode} mode={mode}
onToggleFile={onToggleFile} onToggleFile={onToggleFile}
onSelectPath={onSelectPath} onSelectPath={onSelectPath}
canvasRendered
onActiveChange={setActive}
/> />
</View> </Animated.View>
); );
} }
@@ -382,7 +472,10 @@ function NativeFileBody({
<View <View
testID={`diff-file-${file.fileIndex}-body`} testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({ style={inlineUnistylesStyle<ViewStyle>({
position: "relative", position: "absolute",
top: file.bodyTop,
left: 0,
right: 0,
height: file.bodyHeight, height: file.bodyHeight,
zIndex: 2, zIndex: 2,
})} })}
@@ -579,5 +672,4 @@ function NativeSlabCode({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }, root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
scroll: { backgroundColor: "transparent" }, 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 { InlineReviewAddButton, InlineReviewThread } from "@/review";
import type { ReviewableDiffTarget } from "@/utils/diff-layout"; import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header"; import { DocumentFileHeader } from "./document-file-header";
import {
diffHeaderViewportKey,
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing"; import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets"; import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web"; import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model"; 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 { hasPointerDragStarted } from "./pointer-gesture";
import { createMeasuredAdvances } from "./text-measurement"; import { createMeasuredAdvances } from "./text-measurement";
import { retainDiffViewport } from "./viewport"; import { retainDiffViewport } from "./viewport";
@@ -30,6 +36,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const workspaceCache = useDiffDocumentWorkspaceCache(); const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null); const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null); const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
@@ -46,6 +53,17 @@ export function DiffSurface(props: DiffSurfaceProps) {
dismissSelectionOnClick: boolean; dismissSelectionOnClick: boolean;
} | null>(null); } | null>(null);
const frameRef = useRef<number | 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 resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeReleaseFrameRef = useRef<number | null>(null); const resizeReleaseFrameRef = useRef<number | null>(null);
const resizePointerActiveRef = useRef(false); const resizePointerActiveRef = useRef(false);
@@ -53,6 +71,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
const forcePaintRef = useRef(true); const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 }); const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 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<{ const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>; hit: Extract<DiffHit, { kind: "cell" }>;
left: number; left: number;
@@ -81,12 +103,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
await Promise.all([ await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`), document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${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; await document.fonts.ready;
}, },
createMeasurer: () => createWebTextMeasurer(desiredTypography), createMeasurer: () => createWebTextMeasurer(desiredTypography),
}), }),
[desiredTypography, workspaceCache], [desiredTypography, props.headerTypography, workspaceCache],
); );
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType< const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
typeof workspaceCache.typography typeof workspaceCache.typography
@@ -105,7 +133,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight, lineHeight: desiredTypography.lineHeight,
}); });
} }
return workspaceCache.buildModel({ const next = workspaceCache.buildModel({
files: props.files, files: props.files,
collapsedFilePaths: props.collapsedFilePaths, collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout, layout: props.displayPreferences.layout,
@@ -119,8 +147,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"), binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"), tooLarge: t("workspace.git.diff.tooLarge"),
}, },
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
}); });
return next;
}, [ }, [
fileWindowTop,
measurement, measurement,
props.collapsedFilePaths, props.collapsedFilePaths,
props.displayPreferences.layout, props.displayPreferences.layout,
@@ -132,6 +163,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
desiredTypography.lineHeight, desiredTypography.lineHeight,
loadedTypography, loadedTypography,
viewport.width, viewport.width,
viewport.height,
workspaceCache, workspaceCache,
]); ]);
modelRef.current = model; modelRef.current = model;
@@ -213,6 +245,64 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, },
[paint], [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(() => { useLayoutEffect(() => {
const root = rootRef.current; const root = rootRef.current;
@@ -300,13 +390,37 @@ export function DiffSurface(props: DiffSurfaceProps) {
props.files.map((file) => file.path), props.files.map((file) => file.path),
); );
}, [props.files]); }, [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( useEffect(
() => () => { () => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); 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 // Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
// request so the refreshed renderer can schedule its first paint. // request so the refreshed renderer can schedule its first paint.
frameRef.current = null; frameRef.current = null;
headerFrameRef.current = null;
}, },
[], [],
); );
@@ -331,6 +445,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
(scrollElement: HTMLDivElement) => { (scrollElement: HTMLDivElement) => {
const scrollTop = scrollElement.scrollTop; const scrollTop = scrollElement.scrollTop;
scrollTopRef.current = scrollTop; scrollTopRef.current = scrollTop;
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
scheduleHeaderPaint();
updateInteractionFiles(scrollTop);
if (hasHoveredAffordanceRef.current) { if (hasHoveredAffordanceRef.current) {
hasHoveredAffordanceRef.current = false; hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null); setHoveredAffordance(null);
@@ -355,7 +472,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const nextTop = Math.round(requestedTop * ratio) / ratio; const nextTop = Math.round(requestedTop * ratio) / ratio;
if (nextTop !== currentWindow.top) schedulePaint(false); if (nextTop !== currentWindow.top) schedulePaint(false);
}, },
[schedulePaint, viewport.height], [scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
); );
useEffect(() => { useEffect(() => {
const scroll = scrollRef.current; const scroll = scrollRef.current;
@@ -420,8 +537,20 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, },
[pointHit, schedulePaint], [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( const pointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
updateActiveHeader(event.target);
const drag = dragRef.current; const drag = dragRef.current;
if (drag) { if (drag) {
drag.moved = hasPointerDragStarted({ drag.moved = hasPointerDragStarted({
@@ -463,7 +592,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
selectionRef.current = { anchor: drag.anchor, focus: hit.position }; selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint(); schedulePaint();
}, },
[pointHit, schedulePaint, viewport.width], [pointHit, schedulePaint, updateActiveHeader, viewport.width],
); );
const pointerUp = useCallback( const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
@@ -505,6 +634,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const cancelPointer = useCallback(() => { const cancelPointer = useCallback(() => {
dragRef.current = null; dragRef.current = null;
}, []); }, []);
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
const copy = useCallback( const copy = useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => { (event: React.ClipboardEvent<HTMLDivElement>) => {
if (!selectionRef.current) return; if (!selectionRef.current) return;
@@ -554,12 +684,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
onPointerUp={pointerUp} onPointerUp={pointerUp}
onPointerCancel={cancelPointer} onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer} onLostPointerCapture={cancelPointer}
onPointerLeave={pointerLeave}
onCopy={copy} onCopy={copy}
style={SCROLL_STYLE} style={SCROLL_STYLE}
> >
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}> <div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} /> <canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file) => ( {interactionFiles.map((file) => (
<WebFileHeaderSection key={file.path} file={file}> <WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader <DocumentFileHeader
file={file} file={file}
@@ -567,10 +698,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
mode={props.mode} mode={props.mode}
onToggleFile={props.onToggleFile} onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath} onSelectPath={props.onSelectPath}
canvasRendered
/> />
</WebFileHeaderSection> </WebFileHeaderSection>
))} ))}
{model.files {interactionFiles
.filter((file) => !file.isCollapsed && !model.wrapLines) .filter((file) => !file.isCollapsed && !model.wrapLines)
.map((file) => ( .map((file) => (
<HorizontalScroll <HorizontalScroll
@@ -609,6 +741,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
: null} : null}
</div> </div>
</div> </div>
<canvas
ref={headerCanvasRef}
data-testid="git-diff-header-canvas"
style={HEADER_CANVAS_STYLE}
/>
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} /> <DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
{hoveredAffordance?.hit.target && reviewActions ? ( {hoveredAffordance?.hit.target && reviewActions ? (
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} /> <InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
@@ -686,6 +823,7 @@ function WebFileHeaderSection({
<div style={style}> <div style={style}>
<div <div
data-diff-header="true" data-diff-header="true"
data-diff-header-path={file.path}
style={STICKY_HEADER_STYLE} style={STICKY_HEADER_STYLE}
onMouseDown={preventDocumentMouseSelection} onMouseDown={preventDocumentMouseSelection}
> >
@@ -766,6 +904,7 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
right: 0, right: 0,
zIndex: 3, zIndex: 3,
pointerEvents: "none", pointerEvents: "none",
userSelect: "none",
}; };
const STICKY_HEADER_STYLE: React.CSSProperties = { const STICKY_HEADER_STYLE: React.CSSProperties = {
position: "sticky", position: "sticky",
@@ -789,6 +928,12 @@ const CANVAS_STYLE: React.CSSProperties = {
zIndex: 1, zIndex: 1,
pointerEvents: "none", pointerEvents: "none",
}; };
const HEADER_CANVAS_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
zIndex: 3,
pointerEvents: "none",
};
const AFFORDANCE_STYLE: ViewStyle = { const AFFORDANCE_STYLE: ViewStyle = {
position: "absolute", position: "absolute",
zIndex: 5, zIndex: 5,
@@ -808,6 +953,7 @@ function emptyDiffDocumentModel(input: {
layout: input.layout, layout: input.layout,
wrapLines: input.wrapLines, wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth, viewportWidth: input.viewportWidth,
reviewGeometryKey: "",
}; };
} }
@@ -23,6 +23,66 @@ export interface NativeTextLayout {
paragraphs: Array<Array<Array<SkParagraph | null>>>; 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 { export interface NativeTextLayoutStore {
font: SkFont; font: SkFont;
asciiMetrics: CachedAsciiTextMetrics; asciiMetrics: CachedAsciiTextMetrics;
@@ -61,9 +61,20 @@ export interface DiffPalette {
deletionBackground: string; deletionBackground: string;
emptyBackground: string; emptyBackground: string;
selection: string; selection: string;
headerActiveSurface: string;
headerBorder: string;
statusSuccess: string;
statusDanger: string;
statusWarning: string;
syntax: Record<string, string>; syntax: Record<string, string>;
} }
export interface DiffHeaderTypography {
family: string;
size: number;
statSize: number;
}
export interface DiffTokenRun { export interface DiffTokenRun {
start: number; start: number;
end: number; end: number;
@@ -150,6 +161,7 @@ export interface DiffDocumentModel {
layout: "unified" | "split"; layout: "unified" | "split";
wrapLines: boolean; wrapLines: boolean;
viewportWidth: number; viewportWidth: number;
reviewGeometryKey: string;
} }
export interface TextMeasurer { export interface TextMeasurer {
@@ -168,6 +180,7 @@ export interface BuildDiffDocumentModelInput {
palette: DiffPalette; palette: DiffPalette;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
labels: { binary: string; tooLarge: string }; labels: { binary: string; tooLarge: string };
materializationWindow?: { top: number; height: number };
/** A geometry-compatible model whose unchanged file measurements may be reused. */ /** A geometry-compatible model whose unchanged file measurements may be reused. */
reuseFrom?: readonly DiffDocumentModel[]; reuseFrom?: readonly DiffDocumentModel[];
} }
@@ -206,6 +219,7 @@ export type DiffScrollAnchor =
export type DiffSurfaceProps = DiffDocumentProps & { export type DiffSurfaceProps = DiffDocumentProps & {
palette: DiffPalette; palette: DiffPalette;
headerTypography: DiffHeaderTypography;
collapsedFilePaths: ReadonlySet<string>; collapsedFilePaths: ReadonlySet<string>;
onToggleFile: (path: string) => void; onToggleFile: (path: string) => void;
selectedPath: string | null; selectedPath: string | null;
@@ -1,7 +1,12 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createDiffDocumentWorkspaceCache } from "./workspace-cache"; import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types"; import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
DiffPalette,
TextMeasurer,
} from "./types";
const palette: DiffPalette = { const palette: DiffPalette = {
surface: "#000", surface: "#000",
@@ -15,6 +20,11 @@ const palette: DiffPalette = {
deletionBackground: "#100", deletionBackground: "#100",
emptyBackground: "#111", emptyBackground: "#111",
selection: "blue", selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: { keyword: "purple" }, syntax: { keyword: "purple" },
}; };
@@ -137,6 +147,33 @@ describe("diff document workspace cache", () => {
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" }); 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 () => { it("shares loaded typography and its text measurer across mounts", async () => {
const cache = createDiffDocumentWorkspaceCache(); const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer(); const { measureText } = countingMeasurer();
@@ -171,3 +208,13 @@ describe("diff document workspace cache", () => {
expect(resource.isReady()).toBe(true); 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 { createContext, useContext, useState, type ReactNode } from "react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { buildDiffDocumentModel, retainReusableModels } from "./model"; import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
import type { import type {
BuildDiffDocumentModelInput, BuildDiffDocumentModelInput,
DiffDocumentModel, DiffDocumentModel,
@@ -117,17 +117,11 @@ function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">):
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string { function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
const collapsedFilePaths = [...input.collapsedFilePaths].sort(); const collapsedFilePaths = [...input.collapsedFilePaths].sort();
const reviewGeometry = input.reviewActions const reviewGeometry = reviewGeometryKey(input.reviewActions);
? { const materializationWindow = input.materializationWindow
comments: [...input.reviewActions.commentsByTarget.entries()] ? [input.materializationWindow.top, input.materializationWindow.height]
.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,
}
: null; : null;
return JSON.stringify([collapsedFilePaths, reviewGeometry]); return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
} }
function typographyKey(typography: DiffTypography): string { function typographyKey(typography: DiffTypography): string {
@@ -150,6 +144,11 @@ function paletteKey(palette: DiffPalette): string {
palette.deletionBackground, palette.deletionBackground,
palette.emptyBackground, palette.emptyBackground,
palette.selection, palette.selection,
palette.headerActiveSurface,
palette.headerBorder,
palette.statusSuccess,
palette.statusDanger,
palette.statusWarning,
syntax, syntax,
]); ]);
} }
@@ -1,51 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
reduceFileHeaderPress, reduceFileHeaderPress,
shouldActivateStickyHeaderPress,
type FileHeaderPressEvent, type FileHeaderPressEvent,
type FileHeaderPressPhase, type FileHeaderPressPhase,
} from "./file-header-interaction"; } from "./file-header-interaction";
const pressOrigin = { startedAt: 1_000, pageX: 20, pageY: 30 };
describe("file header interaction arbitration", () => { 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[]) { function run(events: FileHeaderPressEvent[]) {
let phase: FileHeaderPressPhase = "idle"; let phase: FileHeaderPressPhase = "idle";
const effects: string[] = []; const effects: string[] = [];
@@ -57,64 +17,17 @@ describe("file header interaction arbitration", () => {
return effects; return effects;
} }
it("lets a normal press claim a deferred sticky release exactly once", () => { it("activates an actual press", () => {
expect( expect(run([{ type: "press-in" }, { type: "press" }])).toEqual(["activate"]);
run([
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "press" },
{ type: "fallback" },
]),
).toEqual(["defer-activate", "activate"]);
}); });
it("keeps a duplicated wrapper press-out eligible for one fallback", () => { it("does not activate when scrolling cancels the press", () => {
expect( expect(run([{ type: "press-in" }])).toEqual([]);
run([
{ type: "press-in" },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "press-out", stickyFallbackEligible: true },
{ type: "fallback" },
]),
).toEqual(["defer-activate", "activate"]);
}); });
it("falls back once for a canceled sticky press", () => { it("selects a long press without activating when Pressability later emits press", () => {
expect( expect(run([{ type: "press-in" }, { type: "long-press" }, { type: "press" }])).toEqual([
run([ "select",
{ 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"]);
}); });
}); });
+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 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 { interface FileHeaderInteractionInput {
path: string; path: string;
@@ -11,47 +7,22 @@ interface FileHeaderInteractionInput {
onSelect?: (path: string) => void; onSelect?: (path: string) => void;
onActivate?: (path: string) => void; onActivate?: (path: string) => void;
onLayout?: (height: number) => 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 { export interface FileHeaderInteraction {
activate: () => void; activate: () => void;
select: () => void; select: () => void;
onLayout: (event: LayoutChangeEvent) => void; onLayout: (event: LayoutChangeEvent) => void;
onPressIn: (event: PressPointEvent) => void; onPressIn: () => void;
onPressOut: (event: PressPointEvent) => void;
onLongPress: () => void; onLongPress: () => void;
} }
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long"; export type FileHeaderPressPhase = "idle" | "pressed" | "long" | "handled";
export type FileHeaderPressEvent = export type FileHeaderPressEvent =
| { type: "press-in" } | { type: "press-in" }
| { type: "press-out"; stickyFallbackEligible: boolean }
| { type: "press" } | { type: "press" }
| { type: "long-press" } | { type: "long-press" };
| { type: "fallback" }; export type FileHeaderPressEffect = "none" | "select" | "activate";
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
export function reduceFileHeaderPress( export function reduceFileHeaderPress(
phase: FileHeaderPressPhase, phase: FileHeaderPressPhase,
@@ -59,62 +30,14 @@ export function reduceFileHeaderPress(
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } { ): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
if (event.type === "press-in") return { phase: "pressed", effect: "none" }; if (event.type === "press-in") return { phase: "pressed", effect: "none" };
if (event.type === "long-press") return { phase: "long", effect: "select" }; if (event.type === "long-press") return { phase: "long", effect: "select" };
if (event.type === "press-out") { return phase === "long" || phase === "handled"
if (phase === "released" || phase === "handled" || phase === "long") { ? { phase: "handled", effect: "none" }
return { phase, effect: "none" }; : { phase: "handled", effect: "activate" };
}
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
);
} }
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction { export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
const { const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
path,
enabled,
onSelect,
onActivate,
onLayout: notifyLayout,
stickyPressFallback = false,
} = input;
const pressHandledRef = useRef(false);
const pressOriginRef = useRef<PressOrigin | null>(null);
const phaseRef = useRef<FileHeaderPressPhase>("idle"); 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(() => { const select = useCallback(() => {
if (enabled) onSelect?.(path); if (enabled) onSelect?.(path);
@@ -122,14 +45,12 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
const activate = useCallback(() => { const activate = useCallback(() => {
if (!enabled) return; if (!enabled) return;
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" }); const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
phaseRef.current = transition.phase; phaseRef.current = transition.phase;
if (transition.effect !== "activate") return; if (transition.effect !== "activate") return;
pressHandledRef.current = true;
select(); select();
onActivate?.(path); onActivate?.(path);
}, [clearFallback, enabled, onActivate, path, select]); }, [enabled, onActivate, path, select]);
const onLayout = useCallback( const onLayout = useCallback(
(event: LayoutChangeEvent) => { (event: LayoutChangeEvent) => {
@@ -138,61 +59,15 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
[notifyLayout], [notifyLayout],
); );
const onPressIn = useCallback( const onPressIn = useCallback(() => {
(event: PressPointEvent) => { phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
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 onLongPress = useCallback(() => { const onLongPress = useCallback(() => {
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" }); const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
phaseRef.current = transition.phase; phaseRef.current = transition.phase;
pressHandledRef.current = true;
if (transition.effect === "select") select(); if (transition.effect === "select") select();
}, [clearFallback, select]); }, [select]);
const onPressOut = useCallback( return { activate, select, onLayout, onPressIn, onLongPress };
(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 };
} }
@@ -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 { 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 { StyleSheet } from "react-native-unistyles";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -17,6 +22,12 @@ import {
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useFileHeaderInteraction } from "@/git/file-header-interaction"; 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 type { ParsedDiffFile } from "@/git/use-diff-query";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -43,14 +54,8 @@ export interface FileHeaderProps {
onRevert?: (path: string, oldPath?: string) => void; onRevert?: (path: string, oldPath?: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void; onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string; testID?: string;
} canvasRendered?: boolean;
onActiveChange?: (active: boolean) => void;
function fileNameForPath(path: string): string {
return path.split("/").pop() ?? path;
}
function directorySuffix(path: string): string {
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
} }
function fileHeaderAccessibilityState(input: { function fileHeaderAccessibilityState(input: {
@@ -68,12 +73,22 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
return showsBodyState ? bodyVisible : undefined; 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 [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
if (enabled) setIsHovered(true); if (!enabled) return;
}, [enabled]); setIsHovered(true);
const handlePointerLeave = useCallback(() => setIsHovered(false), []); onActiveChange?.(true);
}, [enabled, onActiveChange]);
const handlePointerLeave = useCallback(() => {
setIsHovered(false);
onActiveChange?.(false);
}, [onActiveChange]);
return { isHovered, handlePointerEnter, handlePointerLeave }; return { isHovered, handlePointerEnter, handlePointerLeave };
} }
@@ -96,7 +111,8 @@ function fileHeaderInteractionStyle(input: {
return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active; 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; return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active;
} }
@@ -115,9 +131,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
} }
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" { function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
if (file.isNew) return "added"; return diffFileChangeKind(file);
if (file.isDeleted) return "deleted";
return "modified";
} }
function FileHeaderMenu({ function FileHeaderMenu({
@@ -181,9 +195,11 @@ export const FileHeader = memo(function FileHeader({
onSelect, onSelect,
onHeaderHeightChange, onHeaderHeightChange,
testID, testID,
canvasRendered = false,
onActiveChange,
...actions ...actions
}: FileHeaderProps) { }: FileHeaderProps) {
const hover = useFileHeaderHover(interactive); const hover = useFileHeaderHover(interactive, onActiveChange);
const dragSourceRef = useWorkspaceFileDragSource({ const dragSourceRef = useWorkspaceFileDragSource({
enabled: interactive, enabled: interactive,
disabled: file.isDeleted, disabled: file.isDeleted,
@@ -196,7 +212,6 @@ export const FileHeader = memo(function FileHeader({
enabled: interactive, enabled: interactive,
onSelect, onSelect,
onActivate, onActivate,
stickyPressFallback: showsBodyState,
onLayout: useCallback( onLayout: useCallback(
(height: number) => onHeaderHeightChange?.(file.path, height), (height: number) => onHeaderHeightChange?.(file.path, height),
[file.path, onHeaderHeightChange], [file.path, onHeaderHeightChange],
@@ -221,6 +236,13 @@ export const FileHeader = memo(function FileHeader({
], ],
[depth, hover.isHovered, isSelected, showsBodyState], [depth, hover.isHovered, isSelected, showsBodyState],
); );
const canvasPressableStyle = useCallback(
(state: PressableStateCallbackType) => [
pressableStyle(state),
canvasRendered && styles.canvasInteraction,
],
[canvasRendered, pressableStyle],
);
const accessibilityState = useMemo( const accessibilityState = useMemo(
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }), () => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
[bodyVisible, isSelected, showsBodyState], [bodyVisible, isSelected, showsBodyState],
@@ -260,36 +282,67 @@ export const FileHeader = memo(function FileHeader({
</View> </View>
</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; let trigger: ReactElement;
if (interactive) { if (interactive) {
trigger = ( trigger = (
<ContextMenuTrigger <ContextMenuTrigger
testID={testID ? `${testID}-toggle` : undefined} testID={testID ? `${testID}-toggle` : undefined}
style={pressableStyle} style={canvasPressableStyle}
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)} highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState, canvasRendered)}
cancelable={false} onPressIn={handlePressIn}
onPressIn={interaction.onPressIn} onPressOut={handlePressOut}
onPressOut={interaction.onPressOut}
onLongPress={interaction.onLongPress} onLongPress={interaction.onLongPress}
onContextMenu={interaction.select} onContextMenu={interaction.select}
onPress={interaction.activate} onPress={interaction.activate}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)} aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
aria-selected={isSelected} aria-selected={isSelected}
> >
{content} {renderedContent}
</ContextMenuTrigger> </ContextMenuTrigger>
); );
} else { } else {
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>; trigger = (
<View
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
accessibilityLabel={accessibilityLabel}
>
{renderedContent}
</View>
);
} }
return ( return (
<View <View
style={[styles.container, fileHeaderContainerStyle(showsBodyState)]} style={[
styles.container,
fileHeaderContainerStyle(showsBodyState),
canvasRendered && styles.canvasInteraction,
]}
onLayout={interaction.onLayout} onLayout={interaction.onLayout}
onPointerEnter={hover.handlePointerEnter} onPointerEnter={hover.handlePointerEnter}
onPointerLeave={hover.handlePointerLeave} onPointerLeave={hover.handlePointerLeave}
testID={testID} testID={testID}
accessibilityLabel={accessibilityLabel}
> >
<ContextMenu> <ContextMenu>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
@@ -347,6 +400,13 @@ const styles = StyleSheet.create((theme) => ({
}, },
documentActive: { backgroundColor: theme.colors.surface1 }, documentActive: { backgroundColor: theme.colors.surface1 },
documentPressFeedback: { backgroundColor: theme.colors.surface1 }, documentPressFeedback: { backgroundColor: theme.colors.surface1 },
canvasInteraction: {
backgroundColor: "transparent",
borderColor: "transparent",
elevation: 0,
shadowOpacity: 0,
},
canvasInteractionContent: { flex: 1, minWidth: 0 },
left: { left: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -28,3 +28,11 @@ export function resolveKeyboardShift(input: {
return Math.max(0, input.rawKeyboardHeight - input.bottomInset); 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 { describe, expect, it } from "vitest";
import { import {
shouldReconcileHiddenKeyboardEnd,
resolveKeyboardShift, resolveKeyboardShift,
shouldUseCompactExplorerKeyboardPadding, shouldUseCompactExplorerKeyboardPadding,
} from "./keyboard-shift-policy"; } 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", () => { describe("shouldUseCompactExplorerKeyboardPadding", () => {
it("keeps the changes viewport stable while preserving padding for other tabs", () => { it("keeps the changes viewport stable while preserving padding for other tabs", () => {
expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe( expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe(
@@ -15,6 +15,7 @@ import {
import { import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift, resolveKeyboardShift,
shouldReconcileHiddenKeyboardEnd,
} from "@/hooks/keyboard-shift-policy"; } from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
@@ -34,9 +35,9 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
{ {
onEnd: (event) => { onEnd: (event) => {
"worklet"; "worklet";
if (isIos) { if (isIos && shouldReconcileHiddenKeyboardEnd(event)) {
keyboardHeight.value = -event.height; keyboardHeight.value = 0;
keyboardProgress.value = event.progress; keyboardProgress.value = 0;
} }
}, },
}, },
+1 -4
View File
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
} }
}) })
.onStart(() => { .onStart(() => {
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" }); startedRevision.value = beginGesture({ origin: "agent" });
}) })
.onUpdate((event) => { .onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth); updateGesture(startedRevision.value, -event.translationX / windowWidth);
@@ -188,7 +188,6 @@ export function useCloseAgentListGesture() {
.onStart(() => { .onStart(() => {
startedRevision.value = beginGesture({ startedRevision.value = beginGesture({
origin: "agent-list", origin: "agent-list",
preview: "agent-list",
}); });
}) })
.onUpdate((event) => { .onUpdate((event) => {
@@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
.onStart(() => { .onStart(() => {
startedRevision.value = beginGesture({ startedRevision.value = beginGesture({
origin: "agent", origin: "agent",
preview: "file-explorer",
}); });
}) })
.onUpdate((event) => { .onUpdate((event) => {
@@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() {
.onStart(() => { .onStart(() => {
startedRevision.value = beginGesture({ startedRevision.value = beginGesture({
origin: "file-explorer", origin: "file-explorer",
preview: "file-explorer",
}); });
}) })
.onUpdate((event) => { .onUpdate((event) => {
+104 -7
View File
@@ -4,6 +4,7 @@ import {
canBeginMobilePanelGesture, canBeginMobilePanelGesture,
createMobilePanelMotionState, createMobilePanelMotionState,
getMobilePanelFrame, getMobilePanelFrame,
isMobilePanelActive,
isMobilePanelGestureCurrent, isMobilePanelGestureCurrent,
transitionMobilePanel, transitionMobilePanel,
type MobilePanelCommit, type MobilePanelCommit,
@@ -53,8 +54,8 @@ class MobilePanelsScenario {
return this; return this;
} }
finishAnimation(target: MobilePanelView, revision = this.state.revision) { settleAt(position: number) {
this.dispatch({ type: "animation.finished", revision, target }); this.dispatch({ type: "position.changed", position });
return this; return this;
} }
@@ -77,10 +78,107 @@ class MobilePanelsScenario {
} }
describe("mobile panel ownership", () => { describe("mobile panel ownership", () => {
it("publishes activity only after motion 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 commandTransition = transitionMobilePanel(released, {
type: "command",
selection: { target: "agent-list", revision: 1 },
});
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: "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);
});
it("does not change activity for a cancelled preview", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
const dragging = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
const cancelled = transitionMobilePanel(dragging, {
type: "gesture.finish",
startedRevision: 0,
success: false,
target: "agent-list",
}).state;
expect(isMobilePanelActive(dragging, "agent")).toBe(true);
expect(isMobilePanelActive(cancelled, "agent")).toBe(true);
expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false);
});
it("keeps the visible panel active until its closing motion settles", () => {
const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 });
const closing = transitionMobilePanel(initial, {
type: "command",
selection: { target: "agent", revision: 1 },
}).state;
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
const settled = transitionMobilePanel(closing, {
type: "position.changed",
position: 0,
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
expect(isMobilePanelActive(settled, "agent")).toBe(true);
});
it("follows programmatic commands through left, center, and right", () => { it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario(); const panels = new MobilePanelsScenario();
panels.command("agent-list").finishAnimation("agent-list"); panels.command("agent-list").settleAt(-1);
expect(panels.snapshot()).toEqual({ expect(panels.snapshot()).toEqual({
target: "agent-list", target: "agent-list",
motionTarget: "agent-list", motionTarget: "agent-list",
@@ -88,7 +186,7 @@ describe("mobile panel ownership", () => {
revision: 1, revision: 1,
}); });
panels.command("agent").command("file-explorer").finishAnimation("file-explorer"); panels.command("agent").command("file-explorer").settleAt(1);
expect(panels.snapshot()).toEqual({ expect(panels.snapshot()).toEqual({
target: "file-explorer", target: "file-explorer",
motionTarget: "file-explorer", motionTarget: "file-explorer",
@@ -139,13 +237,12 @@ describe("mobile panel ownership", () => {
expect(panels.commits).toEqual([]); 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(); const panels = new MobilePanelsScenario();
panels.command("agent-list"); panels.command("agent-list");
const staleRevision = panels.snapshot().revision;
panels.command("agent").command("file-explorer"); panels.command("agent").command("file-explorer");
panels.finishAnimation("agent-list", staleRevision); panels.settleAt(-1);
expect(panels.snapshot()).toEqual({ expect(panels.snapshot()).toEqual({
target: "file-explorer", target: "file-explorer",
+37 -22
View File
@@ -21,6 +21,32 @@ export interface MobilePanelTransition {
state: MobilePanelMotionState; 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 = export type MobilePanelEvent =
| { type: "command"; selection: MobilePanelSelection } | { type: "command"; selection: MobilePanelSelection }
| { type: "gesture.begin"; origin: MobilePanelView } | { type: "gesture.begin"; origin: MobilePanelView }
@@ -30,7 +56,7 @@ export type MobilePanelEvent =
success: boolean; success: boolean;
target: MobilePanelView; target: MobilePanelView;
} }
| { type: "animation.finished"; revision: number; target: MobilePanelView }; | { type: "position.changed"; position: number };
export function createMobilePanelMotionState( export function createMobilePanelMotionState(
selection: MobilePanelSelection, selection: MobilePanelSelection,
@@ -52,8 +78,9 @@ export function transitionMobilePanel(
if (event.selection.revision <= state.revision) { if (event.selection.revision <= state.revision) {
return { state }; return { state };
} }
const continuesCurrentMotion = state.motionTarget === event.selection.target;
return { return {
animationTarget: event.selection.target, animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
state: { state: {
...state, ...state,
...event.selection, ...event.selection,
@@ -101,26 +128,7 @@ export function transitionMobilePanel(
}; };
} }
const ownsCurrentRevision = event.revision === state.revision; return settleMobilePanelAtPosition(state, event.position);
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;
} }
export function canBeginMobilePanelGesture( export function canBeginMobilePanelGesture(
@@ -143,6 +151,13 @@ export function isMobilePanelGestureCurrent(
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision; return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
} }
export function isMobilePanelActive(
state: MobilePanelMotionState,
panel: MobilePanelView,
): boolean {
return state.settledTarget === panel;
}
export function getMobilePanelFrame(position: number, width: number) { export function getMobilePanelFrame(position: number, width: number) {
"worklet"; "worklet";
const clampedPosition = Math.max(-1, Math.min(1, position)); const clampedPosition = Math.max(-1, Math.min(1, position));
+16 -11
View File
@@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform";
import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store"; import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { getMobilePanelFrame } from "./model"; import { getMobilePanelFrame } from "./model";
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider"; import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
type OverlayPanel = Exclude<MobilePanelView, "agent">; type OverlayPanel = Exclude<MobilePanelView, "agent">;
@@ -24,10 +24,8 @@ export function MobilePanelOverlay({
panelStyle, panelStyle,
}: MobilePanelOverlayProps) { }: MobilePanelOverlayProps) {
const { position, windowWidth } = useMobilePanelsRuntime(); const { position, windowWidth } = useMobilePanelsRuntime();
const target = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const isOpen = target === panel; const isOpen = useIsMobilePanelActive(panel);
const isPresented = useIsMobilePanelPresented(panel);
const isLeft = panel === "agent-list"; const isLeft = panel === "agent-list";
const sidebarAnimatedStyle = useAnimatedStyle(() => { const sidebarAnimatedStyle = useAnimatedStyle(() => {
@@ -42,10 +40,11 @@ export function MobilePanelOverlay({
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity }; return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]); }, [isLeft, windowWidth]);
const overlayStyle = useMemo( const overlayAnimatedStyle = useAnimatedStyle(() => {
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }], const isVisible = isLeft ? position.value < 0 : position.value > 0;
[isPresented], return { display: isVisible ? ("flex" as const) : ("none" as const) };
); }, [isLeft]);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
const backdropStyle = useMemo( const backdropStyle = useMemo(
() => [styles.backdrop, backdropAnimatedStyle], () => [styles.backdrop, backdropAnimatedStyle],
@@ -73,19 +72,25 @@ export function MobilePanelOverlay({
{/* Fabric needs an always-mounted native host to attach the close handler while the {/* Fabric needs an always-mounted native host to attach the close handler while the
retained panel content is hidden. nativeID keeps that host registered. */} retained panel content is hidden. nativeID keeps that host registered. */}
<View <View
accessibilityElementsHidden={!isOpen}
collapsable={false} collapsable={false}
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
nativeID={`${panel}-gesture-host`} nativeID={`${panel}-gesture-host`}
pointerEvents={overlayPointerEvents} pointerEvents={overlayPointerEvents}
style={styles.overlay} style={styles.overlay}
> >
<View style={overlayStyle} pointerEvents={overlayPointerEvents}> <Animated.View
pointerEvents={overlayPointerEvents}
style={[styles.overlay, overlayAnimatedStyle]}
>
<Pressable <Pressable
accessible={false}
accessibilityElementsHidden accessibilityElementsHidden
importantForAccessibility="no-hide-descendants" importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent} onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"} pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`} testID={isOpen ? `${panel}-backdrop` : undefined}
> >
<Animated.View pointerEvents="none" style={backdropStyle} /> <Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable> </Pressable>
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}> <Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion> <WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
</Animated.View> </Animated.View>
</View> </Animated.View>
</View> </View>
</GestureDetector> </GestureDetector>
); );
+36 -61
View File
@@ -15,6 +15,7 @@ import type { GestureType } from "react-native-gesture-handler";
import { import {
cancelAnimation, cancelAnimation,
Easing, Easing,
useAnimatedReaction,
useSharedValue, useSharedValue,
withTiming, withTiming,
type SharedValue, type SharedValue,
@@ -39,18 +40,6 @@ import {
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const LEFT_PANEL_MASK = 1;
const RIGHT_PANEL_MASK = 2;
function getPanelMask(panel: MobilePanelView): number {
if (panel === "agent-list") {
return LEFT_PANEL_MASK;
}
if (panel === "file-explorer") {
return RIGHT_PANEL_MASK;
}
return 0;
}
interface MobilePanelsRuntime { interface MobilePanelsRuntime {
beginGesture: (input: BeginGestureInput) => number; beginGesture: (input: BeginGestureInput) => number;
@@ -69,7 +58,6 @@ interface MobilePanelsRuntime {
interface BeginGestureInput { interface BeginGestureInput {
origin: MobilePanelView; origin: MobilePanelView;
preview: MobilePanelView;
} }
interface FinishGestureInput { interface FinishGestureInput {
@@ -79,7 +67,7 @@ interface FinishGestureInput {
} }
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null); const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
const MobilePanelPresentationContext = createContext(0); const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
export function MobilePanelsProvider({ children }: { children: ReactNode }) { export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
@@ -92,7 +80,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined); const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined); const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined); const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target)); const [activePanel, setActivePanel] = useState(initialSelection.target);
const setOpenGestureBlocked = useCallback( const setOpenGestureBlocked = useCallback(
(owner: symbol, blocked: boolean) => { (owner: symbol, blocked: boolean) => {
@@ -106,21 +94,33 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
[openGesturesBlocked], [openGesturesBlocked],
); );
const presentPanel = useCallback((panel: MobilePanelView) => { const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
const mask = getPanelMask(panel);
if (mask) {
setPresentedPanels((current) => current | mask);
}
}, []);
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
const selection = usePanelStore.getState().mobilePanel; const selection = usePanelStore.getState().mobilePanel;
if (selection.revision !== revision || selection.target !== panel) { if (selection.revision !== revision || selection.target !== panel) {
return; return;
} }
setPresentedPanels(getPanelMask(panel)); if (isNative && panel !== "agent") {
Keyboard.dismiss();
}
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( const animateTransition = useCallback(
(transition: MobilePanelTransition) => { (transition: MobilePanelTransition) => {
"worklet"; "worklet";
@@ -128,29 +128,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
return; return;
} }
const target = transition.animationTarget; const target = transition.animationTarget;
const revision = transition.state.revision; position.value = withTiming(getMobilePanelAnchor(target), {
position.value = withTiming( duration: ANIMATION_DURATION,
getMobilePanelAnchor(target), easing: ANIMATION_EASING,
{ 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(settlePresentation, target, revision);
},
);
}, },
[motionState, position, settlePresentation], [position],
); );
const applySelection = useCallback( const applySelection = useCallback(
@@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
if (selection === previousState.mobilePanel) { if (selection === previousState.mobilePanel) {
return; return;
} }
if (selection.target !== "agent") {
presentPanel(selection.target);
if (isNative) {
Keyboard.dismiss();
}
}
scheduleOnUI(applySelection, selection); scheduleOnUI(applySelection, selection);
}); });
}, [applySelection, presentPanel]); }, [applySelection]);
const beginGesture = useCallback( const beginGesture = useCallback(
({ origin, preview }: BeginGestureInput): number => { ({ origin }: BeginGestureInput): number => {
"worklet"; "worklet";
const currentState = motionState.value; const currentState = motionState.value;
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) { if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
@@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
}); });
motionState.value = transition.state; motionState.value = transition.state;
cancelAnimation(position); cancelAnimation(position);
scheduleOnRN(presentPanel, preview);
return transition.state.gesture?.startedRevision ?? -1; return transition.state.gesture?.startedRevision ?? -1;
}, },
[motionState, position, presentPanel], [motionState, position],
); );
const updateGesture = useCallback( const updateGesture = useCallback(
@@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
return ( return (
<MobilePanelsContext.Provider value={value}> <MobilePanelsContext.Provider value={value}>
<MobilePanelPresentationContext.Provider value={presentedPanels}> <MobilePanelActiveContext.Provider value={activePanel}>
{children} {children}
</MobilePanelPresentationContext.Provider> </MobilePanelActiveContext.Provider>
</MobilePanelsContext.Provider> </MobilePanelsContext.Provider>
); );
} }
@@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
return context; return context;
} }
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean { export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
const presentedPanels = useContext(MobilePanelPresentationContext); return useContext(MobilePanelActiveContext) === panel;
return (presentedPanels & getPanelMask(panel)) !== 0;
} }
export function useBlockMobilePanelOpenGestures(blocked: boolean): void { export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
@@ -1,4 +1,3 @@
import { Buffer } from "buffer";
import { z } from "zod"; import { z } from "zod";
import { import {
AgentStatusSchema, AgentStatusSchema,
@@ -715,8 +714,32 @@ function pendingRowKey(key: ReplicaRowKey): string {
return `${key.serverId}\u0000${rowKey(key)}`; return `${key.serverId}\u0000${rowKey(key)}`;
} }
function payloadBytes(payload: string): number { // Budget accounting runs over every stored row of a touched host on each persist. Rows are
return Buffer.byteLength(payload, "utf8"); // 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 { function parseJsonPayload(payload: string): unknown {
@@ -1053,7 +1076,7 @@ export class ReplicaCache {
this.storedRows.delete(oldServerId); this.storedRows.delete(oldServerId);
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
this.storedRows.set(newServerId, newRows); 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.delete(oldServerId);
this.hostBytes.set(newServerId, bytes); this.hostBytes.set(newServerId, bytes);
this.totalBytes += bytes; this.totalBytes += bytes;
@@ -1237,17 +1260,17 @@ export class ReplicaCache {
const previous = rows?.get(rowKey(key)); const previous = rows?.get(rowKey(key));
if (previous) { if (previous) {
rows?.delete(rowKey(key)); rows?.delete(rowKey(key));
this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload)); this.adjustHostBytes(key.serverId, -rowBytes(previous));
} }
touchedServerIds.add(key.serverId); touchedServerIds.add(key.serverId);
} }
for (const row of changes.upserts) { for (const row of changes.upserts) {
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>(); const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
const previous = rows.get(rowKey(row)); const previous = rows.get(rowKey(row));
const previousBytes = previous ? payloadBytes(previous.payload) : 0; const previousBytes = previous ? rowBytes(previous) : 0;
rows.set(rowKey(row), row); rows.set(rowKey(row), row);
this.storedRows.set(row.serverId, rows); 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); touchedServerIds.add(row.serverId);
} }
for (const serverId of touchedServerIds) { for (const serverId of touchedServerIds) {
@@ -1288,7 +1311,7 @@ export class ReplicaCache {
for (const [serverId, rows] of projectedRows) { for (const [serverId, rows] of projectedRows) {
projectedBytes.set( projectedBytes.set(
serverId, 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; continue;
} }
const rows = new Map(host.rows.map((row) => [rowKey(row), row])); 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.storedRows.set(host.serverId, rows);
this.hostBytes.set(host.serverId, bytes); this.hostBytes.set(host.serverId, bytes);
this.totalBytes += bytes; this.totalBytes += bytes;
+27 -2
View File
@@ -70,7 +70,6 @@ describe("deriveStreamTurnTiming", () => {
}); });
assert.deepEqual(timing.byAssistantId.get("a1"), { assert.deepEqual(timing.byAssistantId.get("a1"), {
startedAt: userAt,
completedAt: assistantAt, completedAt: assistantAt,
durationMs: 7000, durationMs: 7000,
}); });
@@ -93,11 +92,37 @@ describe("deriveStreamTurnTiming", () => {
}); });
const expected = { const expected = {
startedAt: userAt,
completedAt: lastAssistantAt, completedAt: lastAssistantAt,
durationMs: 7000, durationMs: 7000,
}; };
assert.deepEqual(timing.byAssistantId.get("a1"), expected); assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), 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"; import { startsNewTurn } from "@/agent-stream/turn-membership";
export interface TurnTiming { export interface TurnTiming {
startedAt: Date;
completedAt: Date; completedAt: Date;
durationMs: number; durationMs: number | null;
} }
export interface StreamTurnTiming { export interface StreamTurnTiming {
@@ -25,13 +24,14 @@ export function deriveStreamTurnTiming(params: {
let previousItem: StreamItem | null = null; let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => { const flushCompletedTurn = () => {
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) { if (!currentLastItemAt || currentAssistantIds.length === 0) {
return; return;
} }
const timing: TurnTiming = { const timing: TurnTiming = {
startedAt: currentUserAt,
completedAt: currentLastItemAt, 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) { for (const id of currentAssistantIds) {
byAssistantId.set(id, timing); byAssistantId.set(id, timing);
@@ -45,10 +45,6 @@ export function deriveStreamTurnTiming(params: {
currentLastItemAt = null; currentLastItemAt = null;
currentAssistantIds = []; currentAssistantIds = [];
} }
if (!currentUserAt) {
previousItem = item;
return;
}
currentLastItemAt = item.timestamp; currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") { if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id); currentAssistantIds.push(item.id);
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/server": "0.7.0", "@getpaseo/server": "0.7.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "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({ expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin", 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({ expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
name: "hello-plugin", name: "hello-plugin",
private: true, private: true,
version: "0.0.0", version: "0.0.0",
scripts: { typecheck: "tsc --noEmit" }, scripts: { typecheck: "tsc --noEmit" },
devDependencies: { devDependencies: {
"@getpaseo/plugin": "0.7.0", "@getpaseo/plugin": cliPackageJson.version,
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
react: "19.1.0", react: "19.1.0",
+21 -23
View File
@@ -57,10 +57,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 5", model: "Opus 5",
descriptionFragment: "Latest release", descriptionFragment: "Latest release",
}, },
{
id: "claude-fable-5-1",
model: "Fable 5.1",
descriptionFragment: "Most powerful",
},
{ {
id: "claude-fable-5", id: "claude-fable-5",
model: "Fable 5", model: "Fable 5",
descriptionFragment: "Most powerful", descriptionFragment: "Previous release",
}, },
{ {
id: "claude-opus-4-8[1m]", id: "claude-opus-4-8[1m]",
@@ -161,18 +166,9 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
} }
function assertClaudeModels(data: ProviderModel[]): void { 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 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.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) { for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) {
const actualModel = byId.get(expectedModel.id); const actualModel = byId.get(expectedModel.id);
@@ -187,6 +183,18 @@ function assertClaudeModels(data: ProviderModel[]): void {
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`, `${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 { try {
@@ -411,20 +419,10 @@ try {
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]); const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0"); assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n").filter(Boolean); 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( assert.deepStrictEqual(
[...lines].sort(), lines,
[...claudeModelIdsFromJson].sort(), claudeModelIdsFromJson,
"--quiet should print the same model IDs returned by --json", "--quiet should print the same ordered 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",
); );
assert( assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"), 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}`, `stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
); );
assert( assert(
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') && capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
capturedSupervisorLogs.includes('"signal":"SIGTERM"'), `stop should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
`stop should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`, );
assert(
capturedSupervisorLogs.includes("Server closed"),
`stop should run daemon cleanup before the worker exits, logs:\n${capturedSupervisorLogs}`,
); );
assert( assert(
!capturedSupervisorLogs.includes("cli_shutdown"), !capturedSupervisorLogs.includes("cli_shutdown"),
@@ -234,9 +234,12 @@ try {
`restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`, `restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
); );
assert( assert(
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') && capturedSupervisorLogs.includes('"msg":"Supervisor requesting graceful worker shutdown"'),
capturedSupervisorLogs.includes('"signal":"SIGTERM"'), `restart should log the graceful worker shutdown request, logs:\n${capturedSupervisorLogs}`,
`restart should log supervisor signal dispatch, 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"); console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
} finally { } finally {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo client SDK package", "description": "Paseo client SDK package",
"files": [ "files": [
"dist", "dist",
@@ -39,8 +39,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.0", "@getpaseo/relay": "0.7.2",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.7.0", "version": "0.7.2",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0", "version": "0.7.2",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.7.0", "version": "0.7.2",
"files": [ "files": [
"dist", "dist",
"!dist/**/*.map", "!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo plugin SDK package", "description": "Paseo plugin SDK package",
"files": [ "files": [
"dist", "dist",
@@ -44,8 +44,8 @@
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.1.0", "react": "19.1.0",
@@ -54,8 +54,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"react": "19.1.0", "react": "19.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo shared protocol schemas and wire types", "description": "Paseo shared protocol schemas and wire types",
"files": [ "files": [
"dist", "dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist", "dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.7.0", "version": "0.7.2",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8", "@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0", "@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.0", "@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.0", "@getpaseo/plugin": "0.7.2",
"@getpaseo/protocol": "0.7.0", "@getpaseo/protocol": "0.7.2",
"@getpaseo/relay": "0.7.0", "@getpaseo/relay": "0.7.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@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 repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", 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: { async function runSupervisorFixture(options: {
workerSource: string; workerSource: string;
restartOnCrash?: boolean; restartOnCrash?: boolean;
@@ -172,9 +181,12 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n"); 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({ const result = await runSupervisorFixture({
workerSource: ` workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" }); process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" });
setInterval(() => {}, 1000); setInterval(() => {}, 1000);
`, `,
@@ -184,17 +196,61 @@ describe("supervisor durable logging", () => {
expect(result.signal).toBeNull(); expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker requested shutdown"'); expect(result.log).toContain('"msg":"Worker requested shutdown"');
expect(result.log).toContain('"reason":"client_shutdown_rpc"'); expect(result.log).toContain('"reason":"client_shutdown_rpc"');
expect(result.log).toContain('"msg":"Supervisor sending signal to worker"'); expect(result.log).toContain('"msg":"Supervisor requesting graceful worker shutdown"');
expect(result.log).toContain('"signal":"SIGTERM"');
expect(result.log).toContain('"workerPid":'); 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 () => { test("does not restart a worker based on heartbeat absence", async () => {
const result = await runSupervisorFixture({ const result = await runSupervisorFixture({
timeoutMs: 20_000, timeoutMs: 20_000,
workerSource: ` workerSource: `
import { existsSync, writeFileSync } from "node:fs"; 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"; const marker = process.argv[1] + ".started";
if (!existsSync(marker)) { if (!existsSync(marker)) {
writeFileSync(marker, "started"); writeFileSync(marker, "started");
@@ -216,26 +272,22 @@ describe("supervisor durable logging", () => {
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
}, 25_000); }, 25_000);
test.skipIf(isPlatform("win32"))( test("forces shutdown when a worker ignores the graceful shutdown request", async () => {
"forces shutdown when a worker ignores SIGTERM", const result = await runSupervisorFixture({
async () => { timeoutMs: 15_000,
const result = await runSupervisorFixture({ workerSource: `
timeoutMs: 15_000,
workerSource: `
process.on("SIGTERM", () => {});
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" }); process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
setInterval(() => {}, 1_000); setInterval(() => {}, 1_000);
`, `,
}); });
expect(result.code).toBe(0); expect(result.code).toBe(0);
expect(result.signal).toBeNull(); expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"stalled_worker_shutdown"'); 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(
expect(result.log).toContain('"signal":"SIGKILL"'); '"msg":"Worker did not exit after graceful shutdown request; forcing process tree kill"',
}, );
20_000, }, 20_000);
);
test.skipIf(isPlatform("win32"))( test.skipIf(isPlatform("win32"))(
"restarts after worker exit while a descendant retains the worker stdio", "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 { spawn } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs"; 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"; const marker = process.argv[1] + ".started";
if (!existsSync(marker)) { if (!existsSync(marker)) {
writeFileSync(marker, "started"); writeFileSync(marker, "started");
@@ -255,7 +310,6 @@ describe("supervisor durable logging", () => {
{ detached: true, stdio: ["ignore", "inherit", "inherit"] }, { detached: true, stdio: ["ignore", "inherit", "inherit"] },
); );
descendant.unref(); descendant.unref();
process.on("SIGTERM", () => process.exit(0));
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" }); process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
setInterval(() => {}, 1000); setInterval(() => {}, 1000);
} else { } else {
+41 -12
View File
@@ -33,6 +33,11 @@ interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat"; type: "paseo:supervisor-heartbeat";
} }
interface SupervisorGracefulShutdownMessage {
type: "paseo:graceful-shutdown";
reason: string;
}
interface SupervisorOptions { interface SupervisorOptions {
name: string; name: string;
startupMessage: string; startupMessage: string;
@@ -192,11 +197,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (child !== currentChild) { if (child !== currentChild) {
return; return;
} }
writeLifecycleLog("Worker did not exit after SIGTERM; forcing SIGKILL", { writeLifecycleLog(
reason, "Worker did not exit after graceful shutdown request; forcing process tree kill",
supervisorPid: process.pid, {
workerPid: currentChild.pid ?? null, reason,
}); supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
},
);
void signalProcessTree(currentChild, "SIGKILL").catch((error) => { void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
writeLifecycleLog("Failed to force-kill worker process tree", { writeLifecycleLog("Failed to force-kill worker process tree", {
error: error instanceof Error ? error.message : String(error), 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) { if (!child) {
return; 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, reason,
signal,
supervisorPid: process.pid, 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) => { const requestRestart = (reason: string) => {
@@ -347,7 +376,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
restarting = true; restarting = true;
writeLifecycleLog("Restart requested", { reason }); writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`); log(`${reason}. Stopping worker for restart...`);
signalWorker("SIGTERM", reason); requestWorkerShutdown(reason);
scheduleForceKill(reason); scheduleForceKill(reason);
}; };
@@ -363,7 +392,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
exitSupervisor(0); exitSupervisor(0);
return; return;
} }
signalWorker("SIGTERM", reason); requestWorkerShutdown(reason);
scheduleForceKill(reason); scheduleForceKill(reason);
}; };
@@ -425,6 +425,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((m) => m.id)).toEqual([ expect(models.map((m) => m.id)).toEqual([
"claude-opus-5", "claude-opus-5",
"claude-fable-5-1",
"claude-fable-5", "claude-fable-5",
"claude-fable-5[1m]", "claude-fable-5[1m]",
"claude-opus-4-8[1m]", "claude-opus-4-8[1m]",
@@ -495,6 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
}; };
expect(getThinkingIds("claude-opus-5")).toContain("ultracode"); 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-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
@@ -45,12 +45,19 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true, supportsThinkingDisabled: true,
supportsFastMode: 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", 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. // 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]"], aliases: ["claude-fable-5[1m]"],
label: "Fable 5", label: "Fable 5",
description: "Fable 5 · Most powerful model", description: "Fable 5 · Previous release",
minimumClaudeCodeVersion: "2.1.169", minimumClaudeCodeVersion: "2.1.169",
contextWindowMaxTokens: 1_000_000, contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
@@ -326,7 +333,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
} }
const runtimeMatch = trimmed.match( 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) { if (!runtimeMatch) {
return null; return null;
@@ -371,7 +378,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
} }
const runtimeMatch = trimmed.match( const runtimeMatch = trimmed.match(
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
); );
if (!runtimeMatch) { if (!runtimeMatch) {
return null; return null;
@@ -417,6 +424,14 @@ function normalizeMajorMinorClaudeModelId(
): string | null { ): string | null {
const family = familyValue.toLowerCase(); const family = familyValue.toLowerCase();
const suffix = hasOneMillionContext ? "[1m]" : ""; const suffix = hasOneMillionContext ? "[1m]" : "";
const candidate = `claude-${family}-${major}-${minor}${suffix}`; const candidates = [
return isClaudeManifestModelId(candidate) ? candidate : null; `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(); const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([ expect(models.map((m) => m.id)).toEqual([
"claude-opus-5", "claude-opus-5",
"claude-fable-5-1",
"claude-fable-5", "claude-fable-5",
"claude-fable-5[1m]", "claude-fable-5[1m]",
"claude-opus-4-8[1m]", "claude-opus-4-8[1m]",
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual( expect(contextWindows).toEqual(
new Map([ new Map([
["claude-opus-5", 1_000_000], ["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000], ["claude-fable-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000], ["claude-fable-5[1m]", 1_000_000],
["claude-opus-4-8[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( expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
CLAUDE_DISABLED_THINKING_OPTION_ID, 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(); expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
}); });
@@ -170,6 +175,7 @@ describe("getClaudeModels", () => {
["claude-sonnet-5[1m]", true, "high"], ["claude-sonnet-5[1m]", true, "high"],
["claude-sonnet-5-20260101", true, "high"], ["claude-sonnet-5-20260101", true, "high"],
["claude-fable-5", false, "high"], ["claude-fable-5", false, "high"],
["claude-fable-5-1", false, "high"],
["claude-haiku-4-5", false, undefined], ["claude-haiku-4-5", false, undefined],
["openrouter/anthropic/claude-opus-4-8", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined],
[null, false, undefined], [null, false, undefined],
@@ -458,7 +464,7 @@ describe("Claude Opus 5 catalog", () => {
describe("Claude Fable 5 catalog", () => { describe("Claude Fable 5 catalog", () => {
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => { it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
const fable5Models = getClaudeModels() 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 }) => ({ .map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
id, id,
aliases, 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", () => { describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => { it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); 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-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-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; reason?: string;
}; };
interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface BootstrapResult { interface BootstrapResult {
paseoHome: string; paseoHome: string;
logger: ReturnType<typeof createRootLogger>; logger: ReturnType<typeof createRootLogger>;
@@ -272,13 +268,19 @@ async function main() {
}; };
process.on("message", (message: unknown) => { process.on("message", (message: unknown) => {
if ( if (typeof message !== "object" || message === null || !("type" in message)) {
typeof message === "object" && return;
message !== null && }
"type" in message && const type = (message as { type?: unknown }).type;
(message as SupervisorHeartbeatMessage).type === "paseo:supervisor-heartbeat" if (type === "paseo:supervisor-heartbeat") {
) {
lastSupervisorHeartbeatAt = Date.now(); 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")); process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.7.0", "version": "0.7.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {