Compare commits

..
Author SHA1 Message Date
Mohamed Boudra d8ad116b10 test(app): name host filter helper precisely 2026-08-04 09:35:36 +02:00
Mohamed Boudra b344a3c874 test(app): repair browser e2e coverage
Sidebar display controls moved into paged menus and host colors now apply to the server glyph, leaving main's browser tests on stale interaction and styling contracts. Keep behavioral coverage on the current flows while removing pixel-level rail and alignment assertions that should not gate CI.
2026-08-04 09:28:44 +02:00
121 changed files with 946 additions and 3272 deletions
-59
View File
@@ -1,64 +1,5 @@
# Changelog
## 0.3.0-beta.1 - 2026-08-04
### Added
- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830))
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749))
- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu))
- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680))
- Give projects custom icons ([#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs))
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
- See a project's workspace status while it's collapsed ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi))
- See worktree names in workspace hover cards ([#2711](https://github.com/getpaseo/paseo/pull/2711))
- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger))
- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793))
### Improved
- Paseo starts faster
- Coming back to a recent chat is instant ([#2842](https://github.com/getpaseo/paseo/pull/2842))
- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791))
- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797))
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
- Copying part of an assistant response keeps its lists, links, and code formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808))
- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760))
- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava))
- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn))
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694))
- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8))
- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8))
- Paseo Desktop builds from the Nix flake on macOS ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858))
- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718))
- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775))
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762))
- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777))
- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709))
- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745))
- The composer stays above the keyboard on Android
- Typing with an IME on mobile no longer loses composed text
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699))
- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702))
- Sidebar shortcuts leave focus mode ([#2717](https://github.com/getpaseo/paseo/pull/2717))
- Agents started by the NixOS service no longer run in production mode ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata))
## 0.2.5 - 2026-07-30
### Fixed
-11
View File
@@ -67,17 +67,6 @@ npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
For a production-ID release APK that local Android profiling tools can attach to:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
Or from `packages/app`:
```bash
-16
View File
@@ -96,21 +96,6 @@ Any gap between A and B (margins between siblings inside the same parent) is par
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
backdrop swallowing every click until reload.
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
rendered while the menu is up. `useOpenKebabMenuVisibility`
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
row's own `showKebab`.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
@@ -149,6 +134,5 @@ Before opening a PR that touches hover:
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
+10 -37
View File
@@ -26,17 +26,6 @@ when you have actually looked at it on a phone.
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
## Selecting an item on iOS
An item that closes the menu runs its action after a fixed grace period on iOS, not immediately:
a native presenter launched while UIKit is still tearing down the surface can hang. Both surfaces
unmount the moment the menu closes, so the wait is a timer rather than the surface's own dismissal
callback — a callback fired from inside a subtree that is being removed is racing its own removal,
and it loses. Selections used to be dropped entirely for exactly that reason.
The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second
delay on top of it, and don't read state that the same press mutated.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
@@ -97,32 +86,16 @@ and a column of icons there is decoration competing with the values you actually
## Surface details
- The hover fill is **inset from the surface's edges and rounded** — a chip inside the menu, not a
band across it. The inset is taken _out of_ the row, never added to it: padding gives up what
margin takes, so labels sit at the same 13pt they always did. Insetting by growing the row is how
the menu ends up taller than it started.
- **A row is as tall as what is driving it** — 28pt for a pointer, 40pt below `md` for a thumb. The
split is on breakpoint, not on `presentation`: the compact popover that `compactMode` defaults to
is worked with a thumb just as a sheet is, and sizing off the sheet would leave it at the desktop
height. `md` is where `useIsCompactFormFactor` divides, so row height and the popover/sheet choice
turn over together.
- The desktop height only holds because the label's `lineHeight` is pinned — 18 line + 8 padding +
2 border is exactly 28. Leave it to the platform and content outgrows `minHeight`, which then does
nothing. Compact is the other way round: `minHeight` leads and the label centres in it.
- **A row owns its fill; `MenuPage` owns the spacing between rows.** The vertical inset above the
first row and below the last, and the gap between rows, are the page's — one knob each,
`MENU_ROW_GAP` being the one a redesign turns. A row that carried vertical margin would be
serving both at once, and since margins don't collapse it would land as one unit at the edges and
two between rows, so shrinking the gap would eat the inset with it. Horizontal inset does stay on
the row: there is one left edge and one right edge, so nothing is doubling up.
- `MenuPage` is also what provides a page's depth, so the popover and the sheet cannot disagree
about what a page is. Every entry point goes through it — root, flyout, pushed sheet page.
- The gap reaches a page's **direct children**. A group of rows wrapped in a `View` of its own drops
out of it; at the current gap of zero there is nothing to lose, but anything above zero wants that
wrapper to carry the page style too.
- The separator is the one row-level thing that asks for vertical space of its own, and now that
rows carry none, that is still one number controlling one gap. It takes no horizontal margin and
the page has no horizontal padding, so the rule runs the full width of the surface.
- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
up taller than it started.
- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
and content outgrows `minHeight`, which then does nothing.
- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
own — that is two numbers controlling one gap, and they will disagree.
- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
between `surface1` and `surface2`, which put it within a hair of the hover fill and made
separators disappear against a hovered row.
-2
View File
@@ -70,8 +70,6 @@ The daemon keeps provider snapshots per resolved working directory, with a separ
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
+4 -6
View File
@@ -82,7 +82,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -129,7 +129,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
@@ -238,11 +238,9 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
+1 -1
View File
@@ -1 +1 @@
sha256-6Qx7FBcdaT3IurNfBUGb5I7HQCjZ7Xy0qOcCaHPALI8=
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
+36 -36
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -29874,6 +29874,20 @@
"react-native": "*"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.20.7",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.7.tgz",
"integrity": "sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-markdown-display": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
@@ -35309,7 +35323,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35380,7 +35394,7 @@
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
@@ -36245,20 +36259,6 @@
"dev": true,
"license": "MIT"
},
"packages/app/node_modules/react-native-keyboard-controller": {
"version": "1.21.12",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
"integrity": "sha512-pZJNXS//AhXDD9RvkNBZF5MsHv9qbFpNeHMIZ9ESSUqubNsACU7GyTE4GekIcJ1MBu+r9/TNZQuEfpT9v01HZg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"packages/app/node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
@@ -36363,12 +36363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.0-beta.1",
"@getpaseo/protocol": "0.3.0-beta.1",
"@getpaseo/server": "0.3.0-beta.1",
"@getpaseo/client": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/server": "0.2.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36615,10 +36615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@getpaseo/protocol": "0.3.0-beta.1",
"@getpaseo/relay": "0.3.0-beta.1",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36629,7 +36629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36872,7 +36872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37768,7 +37768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38000,7 +38000,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38013,7 +38013,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38231,15 +38231,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.3.0-beta.1",
"@getpaseo/highlight": "0.3.0-beta.1",
"@getpaseo/protocol": "0.3.0-beta.1",
"@getpaseo/relay": "0.3.0-beta.1",
"@getpaseo/client": "0.2.5",
"@getpaseo/highlight": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38778,7 +38778,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,25 +0,0 @@
name: Release iOS Beta
on:
push:
tags:
- "v*-beta.*"
jobs:
build_ios:
name: Build iOS beta
type: build
params:
platform: ios
profile: production
distribute_ios_beta:
name: Distribute iOS beta
needs: [build_ios]
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
+28 -9
View File
@@ -1,12 +1,9 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
? {
@@ -48,6 +45,30 @@ const buildProfile = isFdroidBuild
],
};
function getNativeBuildVersionCode(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
if (!match) {
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
}
const [, majorText, minorText, patchText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
return versionCode;
}
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
@@ -90,13 +111,13 @@ const variants = {
};
const variant = variants[appVariant] ?? variants.production;
const nativeReleaseVersion = getNativeReleaseVersion(pkg.version);
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
export default {
expo: {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
version: pkg.version,
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
@@ -112,7 +133,7 @@ export default {
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
buildNumber: nativeReleaseVersion.iosBuildNumber,
buildNumber: String(nativeBuildVersionCode),
},
android: {
adaptiveIcon: {
@@ -126,7 +147,7 @@ export default {
usesCleartextTraffic: true,
permissions: buildProfile.androidPermissions,
package: variant.packageId,
versionCode: nativeReleaseVersion.androidVersionCode,
versionCode: nativeBuildVersionCode,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
@@ -172,7 +193,6 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -181,7 +201,6 @@ export default {
},
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -915,6 +915,39 @@ test.describe("Agent message submission", () => {
}
});
test("reconciles an interrupted submission when live acknowledgements are missed", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
const title = "Missed submission acknowledgements";
const prompt = "Interrupt without delivering live acknowledgements.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-missed-acknowledgements-",
title,
model: "one-minute-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
gate.setAgentStreamItemSuppressed("user_message", true);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await submitMessage(page, prompt);
await gate.waitForAgentStreamItem("user_message");
await expectRunningAgentChrome(page, title);
await cancelAgent(page);
await gate.waitForAgentStreamEvent("turn_canceled");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
await expectAgentSurfacesIdle(page, title);
} finally {
gate.setAgentStreamItemSuppressed("user_message", false);
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
});
test("keeps layout stable when submitting to an agent with existing history", async ({
page,
submissionScenario,
@@ -4,7 +4,6 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import {
openAttachmentMenu,
expectAttachmentSheetRowsOnTitleRail,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
@@ -64,22 +63,6 @@ test.describe("Composer attachments", () => {
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("compact Plus menu aligns attachment rows with its sheet title", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-sheet-rails-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width: 390, height: 844 });
await openAttachmentMenu(page);
await expectAttachmentSheetRowsOnTitleRail(page);
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
@@ -108,12 +108,13 @@ test.describe("Host appearance", () => {
await leaveHostAppearanceSettings(page);
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
expect(badgeBox).not.toBeNull();
expect(rowBox).not.toBeNull();
await expect(badge).toHaveCSS("flex-shrink", "1");
expect(badgeBox!.width).toBeGreaterThan(96);
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
@@ -4,7 +4,6 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
openNewWorkspaceComposer,
@@ -19,10 +18,7 @@ import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
@@ -70,11 +66,6 @@ test.describe("New workspace entry points", () => {
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: seeded.workspaceId,
});
const globalButton = page.getByTestId("sidebar-global-new-workspace");
await expect(globalButton).toBeVisible({ timeout: 30_000 });
@@ -86,10 +77,6 @@ test.describe("New workspace entry points", () => {
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceTriggerLabelsAligned(page, {
projectLabel: seeded.projectDisplayName,
hostLabel: "localhost",
});
} finally {
await seeded.cleanup();
}
@@ -51,7 +51,6 @@ import {
type LocalGhPrFixture,
} from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { chooseAddProjectMethod, expectAddProjectPage } from "../support/helpers/add-project-flow";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
@@ -79,7 +78,9 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await selectSidebarStatusGrouping(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -5,7 +5,6 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -133,7 +132,9 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await selectSidebarStatusGrouping(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -3,7 +3,6 @@ import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
// whenever the row was unmounted — a collapsed project section being the common case. It now
@@ -40,7 +39,9 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
}
async function switchToStatusGrouping(page: Page): Promise<void> {
await selectSidebarStatusGrouping(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
}
@@ -6,7 +6,6 @@ import {
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -228,19 +227,18 @@ test.describe("Mobile sidebar panelState transition", () => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
try {
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(row).toHaveCount(1);
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
} finally {
await workspace.cleanup();
}
@@ -16,7 +16,6 @@ import {
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowVisible,
@@ -83,7 +82,9 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await selectSidebarStatusGrouping(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
});
@@ -21,7 +21,6 @@ import {
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -398,7 +397,9 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await selectSidebarStatusGrouping(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-grouping").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
@@ -102,8 +102,6 @@ export async function waitForScrollableChat(
.toBeGreaterThan(input.minScrollableDistance);
}
const WHEEL_SETTLE_TIMEOUT_MS = 5_000;
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
@@ -113,41 +111,31 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
// Letting the scroll animation stop before measuring is an optimization, so it must never be
// the thing that decides the test — the assertion below is. The old wait could not say that:
// it resolved only from a `requestAnimationFrame` sampler started by a `wheel` listener, and
// rAF does not fire at all while the page is occluded, which is reachable on a CI runner and
// stalls the sampler on its first frame. A missed wheel event did the same. Either way the
// test hung until its own timeout with nothing to read. A timed sampler with a deadline
// cannot: worst case it gives up and the poll below reports what the scroll actually did.
const wheelSettled = scroll.evaluate((root: Element, timeoutMs: number) => {
const scrollElement = root as HTMLElement;
return new Promise<void>((resolve) => {
const startedAt = Date.now();
let sawWheel = false;
let previousOffset = scrollElement.scrollTop;
let stableSamples = 0;
root.addEventListener(
"wheel",
() => {
sawWheel = true;
},
{ once: true },
);
const sample = () => {
const currentOffset = scrollElement.scrollTop;
const isStable = sawWheel && Math.abs(currentOffset - previousOffset) <= 0.5;
stableSamples = isStable ? stableSamples + 1 : 0;
previousOffset = currentOffset;
if (stableSamples >= 3 || Date.now() - startedAt >= timeoutMs) {
resolve();
return;
}
setTimeout(sample, 16);
};
sample();
});
}, WHEEL_SETTLE_TIMEOUT_MS);
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
+1 -23
View File
@@ -7,7 +7,6 @@ import { connectWorkspaceSetupClient } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
import { waitForSettledPosition } from "./sheet-layout";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
@@ -72,28 +71,7 @@ export async function pressInterruptShortcut(page: Page): Promise<void> {
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(
page
.getByTestId("message-input-attachment-menu")
.or(page.getByTestId("message-input-attachment-menu-content")),
).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise<void> {
const title = page.getByText("Add attachment", { exact: true });
const firstItemGlyph = page
.getByRole("button", { name: "Add image", exact: true })
.locator("svg")
.first();
await waitForSettledPosition(title);
const [titleBox, glyphBox] = await Promise.all([
title.boundingBox(),
firstItemGlyph.boundingBox(),
]);
if (!titleBox || !glyphBox) {
throw new Error("Attachment sheet geometry could not be measured");
}
expect(Math.abs(glyphBox.x - titleBox.x)).toBeLessThanOrEqual(1);
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
+3 -13
View File
@@ -123,17 +123,11 @@ export async function waitForConnectedHost(
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
export async function openSidebarHostFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// Host filters live a page below the display-preferences root, so the filter rows only exist
// after walking into that branch.
export async function openSidebarHostFilter(page: Page): Promise<void> {
await openSidebarDisplayPreferences(page);
await page.getByTestId("sidebar-display-host-filter").click();
}
@@ -195,7 +189,7 @@ export async function chooseHostBadgeDisplay(
function hostBadge(page: Page, input: HostBadgeTarget) {
return page
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`)
.getByTestId(`host-badge-${input.serverId}`);
.getByTestId(`sidebar-host-badge-${input.serverId}`);
}
interface HostBadgeTarget {
@@ -223,16 +217,12 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
}
// The host sits on the meta line as plain secondary text, so its identity colour lives on the
// server icon alone. The label stays muted like every other item on that line — asserting a
// tinted label or a pill fill would re-assert the design the meta row replaced.
export async function expectHostBadgeTinted(
page: Page,
target: HostBadgeTarget & { color: IdentityColorName },
): Promise<void> {
const badge = hostBadge(page, target);
await expect(badge).toBeVisible({ timeout: 15_000 });
await expect(badge).toHaveText(target.hostName);
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
}
@@ -242,7 +232,7 @@ export async function expectHostAppearancePreview(
): Promise<void> {
const preview = page.getByTestId("host-appearance-preview");
await expect(preview).toBeVisible();
const badge = preview.getByTestId(`host-badge-${input.serverId}`);
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
await expect(badge).toHaveText(input.hostName);
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
}
@@ -235,33 +235,6 @@ export async function expectNewWorkspaceProjectSelected(
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function expectNewWorkspaceTriggerLabelsAligned(
page: Page,
input: { projectLabel: string; hostLabel: string },
): Promise<void> {
const projectTrigger = page.getByRole("button", { name: "Workspace project" });
const hostTrigger = page.getByRole("button", { name: "Host", exact: true });
const projectLabel = projectTrigger.getByText(input.projectLabel, { exact: true });
const hostLabel = hostTrigger.getByText(input.hostLabel, { exact: true });
await Promise.all([
expect(projectLabel).toBeVisible({ timeout: 30_000 }),
expect(hostLabel).toBeVisible({ timeout: 30_000 }),
]);
const [projectTriggerBox, projectLabelBox, hostTriggerBox, hostLabelBox] = await Promise.all([
projectTrigger.boundingBox(),
projectLabel.boundingBox(),
hostTrigger.boundingBox(),
hostLabel.boundingBox(),
]);
if (!projectTriggerBox || !projectLabelBox || !hostTriggerBox || !hostLabelBox) {
throw new Error("New Workspace trigger geometry could not be measured");
}
const projectLabelInset = projectLabelBox.x - projectTriggerBox.x;
const hostLabelInset = hostLabelBox.x - hostTriggerBox.x;
expect(hostLabelInset).toBeCloseTo(projectLabelInset, 0);
}
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
@@ -39,6 +39,7 @@ export interface SeedDaemonClient {
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -166,19 +166,6 @@ export async function expectWorkspaceAbsentFromSidebar(
).toHaveCount(0, { timeout: 30_000 });
}
// The display-preferences menu is one row per decision, and the options sit a page below that
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
// again, this is the only place that has to follow.
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId(branchTestID).click();
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}
@@ -1,15 +0,0 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.trace'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
@@ -1 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -1,19 +0,0 @@
package sh.paseo.trace
import android.os.Trace
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
Function("endSection") {
Trace.endSection()
}
}
}
@@ -1,6 +0,0 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.trace.PaseoNativeTraceModule"]
}
}
@@ -1,5 +0,0 @@
{
"name": "paseo-native-trace",
"version": "0.1.0",
"private": true
}
-41
View File
@@ -1,41 +0,0 @@
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
const stableIosBuildSlot = 999;
function getNativeReleaseVersion(version) {
const match = versionPattern.exec(version);
if (!match) {
throw new Error(`Cannot derive native release version from unsupported version: ${version}`);
}
const [, majorText, minorText, patchText, betaText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
const betaNumber = betaText === undefined ? null : Number(betaText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free native version from: ${version}`);
}
if (betaNumber !== null && (betaNumber < 1 || betaNumber >= stableIosBuildSlot)) {
throw new Error(`iOS beta number must be between 1 and 998: ${version}`);
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
const iosBuildSlot = betaNumber ?? stableIosBuildSlot;
const iosBuildNumber = versionCode * 1_000 + iosBuildSlot;
if (!Number.isSafeInteger(iosBuildNumber)) {
throw new Error(`Derived iOS buildNumber is out of range: ${iosBuildNumber}`);
}
return {
appVersion: `${major}.${minor}.${patch}`,
androidVersionCode: versionCode,
iosBuildNumber: String(iosBuildNumber),
};
}
module.exports = { getNativeReleaseVersion };
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
const { getNativeReleaseVersion } = require("./native-release-version");
describe("native release version", () => {
it("reserves the final iOS build slot for a stable release", () => {
expect(getNativeReleaseVersion("0.2.6")).toEqual({
appVersion: "0.2.6",
androidVersionCode: 2006,
iosBuildNumber: "2006999",
});
});
it("gives each beta a unique iOS build slot under the stable app version", () => {
expect(getNativeReleaseVersion("0.2.6-beta.2")).toEqual({
appVersion: "0.2.6",
androidVersionCode: 2006,
iosBuildNumber: "2006002",
});
});
it("rejects beta numbers that consume the stable iOS build slot", () => {
expect(() => getNativeReleaseVersion("0.2.6-beta.999")).toThrow(
"iOS beta number must be between 1 and 998",
);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"private": true,
"main": "index.ts",
"scripts": {
@@ -100,7 +100,7 @@
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
@@ -1,15 +0,0 @@
const { withAndroidManifest } = require("expo/config-plugins");
function withAndroidProfileable(config) {
return withAndroidManifest(config, (modConfig) => {
const application = modConfig.modResults.manifest.application?.[0];
if (!application) {
throw new Error("Could not enable Android profiling without an application manifest entry");
}
application.profileable = [{ $: { "android:shell": "true" } }];
return modConfig;
});
}
module.exports = withAndroidProfileable;
@@ -111,14 +111,12 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -147,9 +145,8 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -666,7 +663,6 @@ function WorkspaceRowRightGroup({
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
@@ -678,10 +674,9 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -978,10 +973,9 @@ function ProjectHeaderRow({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<PressHighlight
<Pressable
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -989,7 +983,7 @@ function ProjectHeaderRow({
testID={`sidebar-project-row-${project.viewKey}`}
>
{rowChildren}
</PressHighlight>
</Pressable>
</View>
);
}
@@ -1007,7 +1001,6 @@ function ProjectHeaderRow({
enabledOnMobile={false}
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -1126,7 +1119,6 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -1896,14 +1888,23 @@ export function SidebarWorkspaceList({
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const localDaemon = useLocalDaemonServerIdState();
const rowItems = useSidebarRowItems();
// Host badge visibility is a lattice, not three competing switches: this gate is the global
// "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says
// nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the
// item off here removes the badge everywhere; leaving it on defers to the per-host setting.
const hostBadgeByServerId = useHostBadges({
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
});
const hostBadgeByServerId = useMemo(
() =>
selectHostBadges({
hosts,
localServerId,
localHostResolutionPending: localDaemon.status !== "resolved",
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
}),
[hosts, localDaemon.status, localServerId, projects, rowItems.host],
);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
@@ -41,7 +41,6 @@ import {
SidebarWorkspaceTrailingActionSlot,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
@@ -730,7 +729,6 @@ function StatusWorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -814,16 +812,14 @@ function StatusWorkspaceActionSlot({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const kebab = useOpenKebabMenuVisibility(showKebab);
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
{showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -71,18 +71,9 @@ export interface SidebarWorkspaceMenuProps {
isPinned?: boolean;
onTogglePin?: () => void;
openInFileManagerPath?: string | null;
/**
* Lifted so the row that reveals the kebab can keep it mounted while its menu is up. See
* `useOpenKebabMenuVisibility`.
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface SidebarWorkspaceMenuItemsProps extends Omit<
SidebarWorkspaceMenuProps,
"onArchive" | "open" | "onOpenChange"
> {
interface SidebarWorkspaceMenuItemsProps extends Omit<SidebarWorkspaceMenuProps, "onArchive"> {
onArchive?: () => void;
}
@@ -211,12 +202,10 @@ export function SidebarWorkspaceMenu({
isPinned,
onTogglePin,
openInFileManagerPath,
open,
onOpenChange,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
return (
<DropdownMenu compactMode="sheet" open={open} onOpenChange={onOpenChange}>
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
hitSlop={8}
style={triggerStyle}
@@ -250,7 +239,7 @@ export function SidebarWorkspaceMenu({
type ContextTriggerProps = Omit<
ComponentProps<typeof ContextMenuTrigger>,
"children" | "enabledOnMobile" | "highlightStyle"
"children" | "enabledOnMobile"
>;
export function SidebarWorkspaceContextMenu({
@@ -275,7 +264,6 @@ export function SidebarWorkspaceContextMenu({
onTogglePin,
openInFileManagerPath,
accessibilityLabel,
highlightStyle,
...triggerProps
}: PropsWithChildren<
SidebarWorkspaceMenuItemsProps &
@@ -286,7 +274,6 @@ export function SidebarWorkspaceContextMenu({
leadingProjectName?: string | null;
hostBadgeLabel?: string | null;
scriptSummary?: WorkspaceScriptSummary | null;
highlightStyle: ComponentProps<typeof ContextMenuTrigger>["highlightStyle"];
}
>) {
const {
@@ -314,7 +301,6 @@ export function SidebarWorkspaceContextMenu({
{...triggerProps}
enabledOnMobile={false}
accessibilityLabel={accessibilityLabel ?? rowAccessibilityLabel}
highlightStyle={highlightStyle}
>
{children}
</ContextMenuTrigger>
@@ -32,7 +32,6 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
@@ -325,7 +324,6 @@ function WorkspaceRowBody({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={draggable ? interaction.handlePressIn : undefined}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={draggable ? interaction.handlePressOut : undefined}
@@ -421,7 +419,6 @@ function WorkspaceRowTrailingActions({
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
@@ -433,10 +430,9 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -1,20 +0,0 @@
import { useMemo, useState } from "react";
/**
* Keeps a row's kebab in its slot for as long as the menu it opened is up.
*
* The kebab is revealed by hover, and the surface it opens can take the pointer off the row —
* on a compact layout the sheet slides up over it. The row then reads as un-hovered, the
* trailing overlay unmounts its own trigger mid-gesture, and the open menu is left with no
* state that can close it: on a sheet that strands a full-screen backdrop over the app until
* reload. Lifting the menu's open state to the component that decides whether to render it is
* what stops the row from pulling the ground out from under it.
*/
export function useOpenKebabMenuVisibility(showKebab: boolean): {
showKebab: boolean;
menuProps: { open: boolean; onOpenChange: (open: boolean) => void };
} {
const [open, setOpen] = useState(false);
const menuProps = useMemo(() => ({ open, onOpenChange: setOpen }), [open]);
return { showKebab: showKebab || open, menuProps };
}
@@ -8,10 +8,11 @@ import {
GitPullRequest,
GitPullRequestClosed,
Globe,
Server,
SquareTerminal,
} from "lucide-react-native";
import type { HostBadgeModel } from "@/hosts/appearance";
import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
import { identityColor } from "@/styles/identity-colors";
import type { PrHint } from "@/git/pr-hint";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -26,11 +27,11 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr
/**
* One size for every glyph on the line. The items are peers — host, change request, CI,
* running service — so a glyph that differs in size reads as a different rank. The host badge
* owns the size because it is the one item that also appears off this line.
* running service — so a glyph that differs in size reads as a different rank.
*/
const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const META_ICON_SIZE = 12;
const ThemedServer = withUnistyles(Server);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
@@ -38,10 +39,21 @@ const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
// Theme-independent, so the mapping per host color is fixed for the life of the module. A
// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass.
const HOST_ICON_MAPPINGS: Record<HostColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) });
}
return byColor;
})();
/**
* The subtitle under a workspace title: which host it lives on, its change request, that
* change request's CI, and any running service. Everything the row knows about a workspace
@@ -91,7 +103,7 @@ function MetaItemNode({
hostBadge: HostBadgeModel | null;
}): ReactNode {
if (item.kind === "host") {
return hostBadge ? <HostBadge badge={hostBadge} /> : null;
return hostBadge ? <HostItem hostBadge={hostBadge} /> : null;
}
if (item.kind === "changeRequest") {
return <PullRequestItem hint={item.hint} />;
@@ -102,6 +114,23 @@ function MetaItemNode({
return <ScriptItem summary={item.summary} />;
}
function HostItem({ hostBadge }: { hostBadge: HostBadgeModel }) {
return (
<View
style={styles.hostItem}
testID={`sidebar-host-badge-${hostBadge.serverId}`}
accessibilityLabel={hostBadge.label}
>
<ThemedServer size={META_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[hostBadge.color]} />
{hostBadge.showLabel ? (
<Text style={styles.hostLabel} numberOfLines={1}>
{hostBadge.label}
</Text>
) : null}
</View>
);
}
/**
* The only thing on the line that navigates, so it is the only thing that answers the
* pointer: it brightens to full foreground and its icon becomes an external-link arrow,
@@ -251,6 +280,15 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 0,
},
// The host is the only unbounded item, so it consumes the remaining width and gives way
// before the bounded change request, CI, and service items.
hostItem: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
itemPressed: {
opacity: 0.82,
},
@@ -260,6 +298,13 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 16,
flexShrink: 0,
},
hostLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 1,
minWidth: 0,
},
scriptPort: {
color: theme.colors.statusMutedSuccess,
fontSize: theme.fontSize.xs,
+43 -66
View File
@@ -53,10 +53,6 @@ import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
import {
createTerminalResizeDebouncer,
type TerminalResizeRequest,
} from "./terminal-resize-debouncer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
@@ -89,7 +85,6 @@ interface TerminalPaneProps {
}
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -764,70 +759,52 @@ export function TerminalPane({
],
);
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
const nextSize = { rows: input.rows, cols: input.cols };
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
});
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: input.rows,
cols: input.cols,
intent: claim.intent,
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
});
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
});
}
});
const terminalResizeDebouncer = useMemo(
() =>
createTerminalResizeDebouncer({
delayMs: TERMINAL_RESIZE_DEBOUNCE_MS,
emit: sendTerminalResize,
}),
[sendTerminalResize],
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
intent: claim.intent,
});
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
});
}
},
);
useEffect(
() => () => terminalResizeDebouncer.cancel(),
[terminalResizeDebouncer, terminalStreamKey],
);
const handleTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
if (input.rows <= 0 || input.cols <= 0) {
return;
}
const nextResize = {
...input,
rows: Math.floor(input.rows),
cols: Math.floor(input.cols),
};
measuredTerminalSizeRef.current = { rows: nextResize.rows, cols: nextResize.cols };
terminalResizeDebouncer.schedule(nextResize);
});
const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
sendTerminalKey(input);
@@ -1,68 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTerminalResizeDebouncer } from "./terminal-resize-debouncer";
describe("terminal resize debouncer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("emits only the latest size after resize callbacks settle", () => {
vi.useFakeTimers();
const emitted: Array<{
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}> = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
vi.advanceTimersByTime(50);
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
vi.advanceTimersByTime(99);
expect(emitted).toEqual([]);
vi.advanceTimersByTime(1);
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: false }]);
});
it("preserves a claim while taking the latest dimensions", () => {
vi.useFakeTimers();
const emitted: Array<{
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}> = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: true, forceClaim: true });
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
vi.runAllTimers();
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: true, forceClaim: true }]);
});
it("cancels a pending resize", () => {
vi.useFakeTimers();
const emitted: unknown[] = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
debouncer.cancel();
vi.runAllTimers();
expect(emitted).toEqual([]);
});
});
@@ -1,47 +0,0 @@
export interface TerminalResizeRequest {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}
export function createTerminalResizeDebouncer(input: {
delayMs: number;
emit: (resize: TerminalResizeRequest) => void;
}): {
schedule: (resize: TerminalResizeRequest) => void;
cancel: () => void;
} {
let timeout: ReturnType<typeof setTimeout> | null = null;
let pending: TerminalResizeRequest | null = null;
const cancel = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
pending = null;
};
return {
schedule: (resize) => {
pending = {
...resize,
shouldClaim: (pending?.shouldClaim ?? false) || resize.shouldClaim,
forceClaim: (pending?.forceClaim ?? false) || resize.forceClaim || undefined,
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
timeout = null;
const resizeToEmit = pending;
pending = null;
if (resizeToEmit) {
input.emit(resizeToEmit);
}
}, input.delayMs);
},
cancel,
};
}
@@ -7,6 +7,7 @@ import {
} from "react";
import {
Platform,
Pressable,
StatusBar,
View,
type GestureResponderEvent,
@@ -27,7 +28,6 @@ import {
type MenuSurfaceProps,
type MenuTriggerState,
} from "@/components/ui/menu";
import { PressHighlight } from "@/components/ui/press-highlight";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
@@ -99,7 +99,6 @@ type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => Sty
export function ContextMenuTrigger({
children,
disabled,
highlightStyle,
style,
enabled = true,
enabledOnMobile = true,
@@ -109,7 +108,6 @@ export function ContextMenuTrigger({
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
highlightStyle?: StyleProp<ViewStyle>;
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
@@ -186,7 +184,7 @@ export function ContextMenuTrigger({
);
return (
<PressHighlight
<Pressable
{...props}
ref={handleRef}
collapsable={false}
@@ -196,9 +194,8 @@ export function ContextMenuTrigger({
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
style={pressableStyle}
highlightStyle={highlightStyle}
>
{children}
</PressHighlight>
</Pressable>
);
}
@@ -1,63 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { Platform } from "react-native";
import { IOS_TEARDOWN_GRACE_MS, useMenuState } from "./menu-context";
/**
* `Platform.OS` is a plain property on the react-native-web double, so the iOS branch is
* reachable by writing to it. The grace period only exists on iOS, which is exactly the platform
* whose selections used to be dropped.
*/
const originalPlatform = Platform.OS;
function setPlatform(os: string) {
Object.assign(Platform, { OS: os });
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
setPlatform(originalPlatform);
});
describe("useMenuState selectItem on iOS", () => {
beforeEach(() => setPlatform("ios"));
it("runs a closing item's action once the surface has had time to go away", () => {
const onSelect = vi.fn();
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
act(() => result.current.selectItem(onSelect, true));
expect(result.current.open).toBe(false);
expect(onSelect).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(IOS_TEARDOWN_GRACE_MS));
expect(onSelect).toHaveBeenCalledTimes(1);
});
it("leaves the menu open for an item that does not close it", () => {
const onSelect = vi.fn();
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
act(() => result.current.selectItem(onSelect, false));
expect(result.current.open).toBe(true);
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
describe("useMenuState selectItem elsewhere", () => {
beforeEach(() => setPlatform("android"));
it("runs a closing item's action immediately", () => {
const onSelect = vi.fn();
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
act(() => result.current.selectItem(onSelect, true));
expect(result.current.open).toBe(false);
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
@@ -24,14 +24,12 @@ export type MenuPresentation = "popover" | "sheet";
/** What a menu does on a compact screen. Wide screens are always a popover. */
export type MenuCompactMode = "popover" | "sheet";
/** How long UIKit is given to finish removing the menu surface before an action runs. */
export const IOS_TEARDOWN_GRACE_MS = 250;
export interface MenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
/** Runs an item's action, closing the menu first when the item asks for it. */
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
/** Point anchor for menus opened from a gesture rather than a trigger box. */
anchorRect: Rect | null;
@@ -106,6 +104,7 @@ export function useMenuState({
compactMode?: MenuCompactMode;
}): MenuContextValue {
const triggerRef = useRef<View>(null);
const pendingSelectRef = useRef<(() => void) | null>(null);
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
const isCompact = useIsCompactFormFactor();
@@ -127,6 +126,21 @@ export function useMenuState({
[setIsOpenState],
);
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
pendingSelectRef.current = null;
if (!pendingSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while an RN
// Modal is still completing dismissal on UIKit's side.
setTimeout(pendingSelect, 250);
return;
}
pendingSelect();
}, []);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
@@ -134,19 +148,14 @@ export function useMenuState({
return;
}
setOpen(false);
if (!onSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while UIKit is still
// tearing down the surface this menu was drawn in. The wait is timed rather than
// driven by the surface's own dismissal callback: both surfaces unmount as soon as
// the menu closes, so any such callback is racing its own removal and usually loses.
setTimeout(onSelect, IOS_TEARDOWN_GRACE_MS);
pendingSelectRef.current = onSelect ?? null;
setOpen(false);
return;
}
onSelect();
setOpen(false);
onSelect?.();
},
[setOpen],
);
@@ -168,6 +177,7 @@ export function useMenuState({
open: isOpen,
setOpen,
selectItem,
flushPendingSelect,
triggerRef,
anchorRect,
setAnchorRect,
@@ -177,6 +187,17 @@ export function useMenuState({
closeSub,
goBack,
}),
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
[
isOpen,
setOpen,
selectItem,
flushPendingSelect,
anchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
],
);
}
@@ -17,7 +17,7 @@ import { Check, CheckCircle } from "lucide-react-native";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
import { MenuDepthProvider, useMenuContext } from "./menu-context";
import { useMenuContext } from "./menu-context";
const ThemedCheck = withUnistyles(Check);
const ThemedCheckCircle = withUnistyles(CheckCircle);
@@ -28,55 +28,20 @@ const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted })
const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
/**
* Height of the filled part of a row, which is also its hit target.
* Height of the filled part of a row, which together with the uniform inset puts the row pitch
* back at the 36pt it was before the fill was inset at all — the space is redistributed, not
* added.
*
* A pointer aims; a thumb lands, so the row is sized by what is driving it. The split is on
* breakpoint rather than on `presentation`, because the compact popover — what `compactMode`
* defaults to — is worked with a thumb just as a sheet is, and would keep the desktop height if
* the sheet were the thing being asked about. `md` is where `useIsCompactFormFactor` divides, so
* this and the popover/sheet choice always turn over together.
*
* On desktop the number is exactly the content: 18 line + 8 padding + 2 border. Leave the text's
* `lineHeight` to the platform and the content outgrows `minHeight`, which then does nothing and
* the rows drift taller again. On compact, `minHeight` leads instead and the label centres in it.
* It only holds if the label's line box is pinned: 18 line + 8 padding + 2 border is exactly
* this. Leave the text's `lineHeight` to the platform and the content outgrows `minHeight`,
* which then does nothing and the rows drift taller again.
*/
const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
const MENU_ITEM_HEIGHT = 28;
const MENU_ITEM_LINE_HEIGHT = 18;
/**
* Space between two rows, owned by the page rather than the rows. Zero because only one row is
* ever filled at a time, so a gap here buys nothing on the common frame and only costs pitch.
*
* This is the knob a redesign turns. Note it applies to a page's direct children, so a group of
* rows wrapped in a `View` of its own would not receive it — at zero there is nothing to lose,
* but anything above it wants the wrapper to carry the same style.
*/
const MENU_ROW_GAP = 0;
/** Action status for menu items with loading/success feedback. */
export type ActionStatus = "idle" | "pending" | "success";
/**
* One page of rows — the root surface, a flyout, or a pushed sheet page.
*
* It owns the vertical spacing of the menu: the inset above the first row and below the last,
* and the gap between rows. A row knows how to draw its own fill and nothing about where it sits
* in a list, so a redesign retunes the rhythm here and never touches `MenuItem`.
*
* Horizontal inset stays on the row. There is one left edge and one right edge, so a row's
* horizontal margin is not standing in for anything else, and leaving it there keeps labels,
* hints and the custom headers callers render into a surface aligned on the same 12pt.
*
* It also carries the page's depth, so the two presentations cannot disagree about what a page is.
*/
export function MenuPage({ depth, children }: PropsWithChildren<{ depth: number }>): ReactElement {
return (
<MenuDepthProvider value={depth}>
<View style={styles.page}>{children}</View>
</MenuDepthProvider>
);
}
export function MenuLabel({
children,
style,
@@ -282,10 +247,6 @@ export function MenuItem({
}
const styles = StyleSheet.create((theme) => ({
page: {
paddingVertical: theme.spacing[1],
gap: MENU_ROW_GAP,
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
@@ -299,15 +260,10 @@ const styles = StyleSheet.create((theme) => ({
// made separators vanish against a hovered row. `borderAccent` is the colour the menu surface
// already outlines itself with, so the divider reads as part of the same frame.
//
// The one thing on a page that wants more room than the row gap gives it, so it says so here.
// That is one number controlling one gap: rows no longer carry vertical spacing of their own,
// so there is nothing left for this to double up with.
//
// No horizontal margin, and the page has no horizontal padding, so the rule still runs the
// full width of the surface rather than reading as an inset tick between two chips.
// No margin of its own: the items above and below already hold themselves off it. Giving the
// rule its own spacing on top of theirs is how you end up tuning two numbers to control one gap.
separator: {
height: 1,
marginVertical: theme.spacing[1],
backgroundColor: theme.colors.borderAccent,
},
hintContainer: {
@@ -322,22 +278,21 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
// The fill is inset from the surface's edges and rounded, so a hovered row reads as a chip
// sitting inside the menu rather than a band across it.
// The fill is inset by the same amount on every side and rounded, so a hovered row reads as a
// chip sitting inside the menu. That inset is the only thing that spaces this menu: rows sit
// apart because each holds itself in, and a separator gets clearance from its neighbours for
// free instead of carrying its own margin.
//
// The inset is taken out of the row, not added to it: margin 4 + padding 8 + border 1 lands the
// label at the same 13pt it always sat at.
//
// Horizontal only. Vertical spacing — the inset above the first row and below the last, and the
// gap between rows — belongs to `MenuPage`. A margin here would have to be both of those at
// once, and since margins do not collapse it would land as one unit at the edges and two
// between rows, so shrinking the gap would eat the inset with it.
// The inset is taken out of the row, not added to it. Horizontally that means padding gives up
// what margin takes — margin 4 + padding 8 + border 1 lands the label at the same 13pt it
// always sat at. Vertically the fill itself is shorter by the same 8, so the pitch is unchanged
// and the menu did not grow; it just stopped filling every row edge to edge.
item: {
flexDirection: "row",
alignItems: "center",
minHeight: MENU_ITEM_HEIGHT,
gap: theme.spacing[2],
marginHorizontal: theme.spacing[1],
margin: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderWidth: theme.borderWidth[1],
@@ -384,10 +384,12 @@ export function AnchoredSurface({
export function MenuOverlay({
visible,
onClose,
onDismiss,
children,
}: {
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
children: ReactElement | null;
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
@@ -436,6 +438,7 @@ export function MenuOverlay({
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onDismiss={onDismiss}
onRequestClose={onClose}
>
{overlay}
@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
describe("getMenuSheetBottomPadding", () => {
it("keeps breathing room below the last row when there is no system inset", () => {
expect(
getMenuSheetBottomPadding({
safeAreaBottom: 0,
breathingRoom: 4,
}),
).toBe(4);
});
it("adds breathing room after the system navigation inset", () => {
expect(
getMenuSheetBottomPadding({
safeAreaBottom: 48,
breathingRoom: 4,
}),
).toBe(52);
});
});
@@ -1,6 +0,0 @@
export function getMenuSheetBottomPadding(input: {
safeAreaBottom: number;
breathingRoom: number;
}): number {
return input.safeAreaBottom + input.breathingRoom;
}
@@ -14,18 +14,15 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { ChevronLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
type ContextBridge,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { SPACING, type Theme } from "@/styles/theme";
import { useMenuContext, MenuContextProvider } from "./menu-context";
import { MenuPage } from "./menu-item";
import type { Theme } from "@/styles/theme";
import { useMenuContext, MenuContextProvider, MenuDepthProvider } from "./menu-context";
import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
import type { Alignment, Placement } from "./menu-anchor";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
@@ -208,7 +205,7 @@ function MenuPopoverSurface({
return (
<MenuSurfaceContext.Provider value={surfaceValue}>
<MenuOverlay visible={menu.open} onClose={handleClose}>
<MenuOverlay visible={menu.open} onClose={handleClose} onDismiss={menu.flushPendingSelect}>
<>
<AnchoredSurface
open={menu.open}
@@ -227,7 +224,7 @@ function MenuPopoverSurface({
scrollable={scrollable}
testID={testID}
>
<MenuPage depth={0}>{children}</MenuPage>
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
</AnchoredSurface>
{openPages.map(({ page, depth }) => (
<MenuFlyout
@@ -294,7 +291,7 @@ function MenuFlyout({
onPointerLeave={handleHoverOut}
testID={testID}
>
<MenuPage depth={depth + 1}>{page.content}</MenuPage>
<MenuDepthProvider value={depth + 1}>{page.content}</MenuDepthProvider>
</AnchoredSurface>
);
}
@@ -307,17 +304,6 @@ function MenuSheetSurface({
}: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors();
const safeAreaInsets = useSafeAreaInsets();
const sheetScrollContentStyle = useMemo(
() => ({
paddingBottom: getMenuSheetBottomPadding({
safeAreaBottom: safeAreaInsets.bottom,
breathingRoom: SPACING[1],
}),
}),
[safeAreaInsets.bottom],
);
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
@@ -369,7 +355,7 @@ function MenuSheetSurface({
keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
contentContainerStyle={sheetScrollContentStyle}
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
@@ -377,12 +363,12 @@ function MenuSheetSurface({
{openPage ? (
<>
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
<MenuPage depth={depth}>{openPage.content}</MenuPage>
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
</>
) : (
<>
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
<MenuPage depth={0}>{children}</MenuPage>
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
</>
)}
</BottomSheetScrollView>
@@ -420,6 +406,9 @@ function MenuSheetHeader({
}
const styles = StyleSheet.create((theme) => ({
sheetScrollContent: {
paddingBottom: theme.spacing[6],
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
@@ -1,74 +0,0 @@
import { forwardRef, useMemo } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { resolveHighlightCorners } from "./press-highlight.shape";
import type { PressHighlightProps } from "./press-highlight.types";
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{ children, disabled, highlightStyle, ...props },
ref,
) {
const highlighted = useSharedValue(0);
const corners = useMemo(() => resolveHighlightCorners(props.style), [props.style]);
const animatedHighlightStyle = useAnimatedStyle(() => ({ opacity: highlighted.value }));
// This gesture owns visual acknowledgement only. Pressable remains the sole owner of
// activation, cancellation, accessibility, and the caller's drag/long-press callbacks.
const pressGesture = useMemo(
() =>
Gesture.Tap()
.enabled(disabled !== true && highlightStyle != null)
.maxDistance(8)
.shouldCancelWhenOutside(true)
.onBegin(() => {
highlighted.value = 1;
})
.onFinalize(() => {
highlighted.value = 0;
}),
[disabled, highlightStyle, highlighted],
);
const highlight = highlightStyle ? (
<Animated.View pointerEvents="none" style={[styles.highlight, animatedHighlightStyle]}>
{/* Keep the themed Unistyles node separate from the node Reanimated patches. The
pressable's corners come first so `highlightStyle` can still override them. */}
<View style={[styles.highlightFill, corners, highlightStyle]} />
</Animated.View>
) : null;
const renderedChildren =
typeof children === "function" ? (
(state: Parameters<typeof children>[0]) => (
<>
{highlight}
{children(state)}
</>
)
) : (
<>
{highlight}
{children}
</>
);
const pressable = (
<Pressable ref={ref} {...props} collapsable={false} disabled={disabled}>
{renderedChildren}
</Pressable>
);
if (!highlightStyle) {
return pressable;
}
return <GestureDetector gesture={pressGesture}>{pressable}</GestureDetector>;
});
const styles = StyleSheet.create({
highlight: {
...StyleSheet.absoluteFillObject,
},
highlightFill: {
...StyleSheet.absoluteFillObject,
},
});
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StyleProp, ViewStyle } from "react-native";
import { resolveHighlightCorners } from "./press-highlight.shape";
describe("resolveHighlightCorners", () => {
it("takes the radius from the pressable's own style, the way a sidebar row declares it", () => {
const row = { minHeight: 36, borderRadius: 8, backgroundColor: "#111" };
expect(resolveHighlightCorners(row)).toEqual({ borderRadius: 8 });
});
it("reads through the style array a row builds per interaction state", () => {
const row: StyleProp<ViewStyle> = [{ borderRadius: 8 }, false, { backgroundColor: "#222" }];
expect(resolveHighlightCorners(row)).toEqual({ borderRadius: 8 });
});
it("keeps the last radius when a later entry in the array reshapes the row", () => {
expect(resolveHighlightCorners([{ borderRadius: 8 }, { borderRadius: 999 }])).toEqual({
borderRadius: 999,
});
});
it("carries every corner a row sets individually", () => {
const row = { borderTopLeftRadius: 8, borderBottomRightRadius: 2, paddingLeft: 12 };
expect(resolveHighlightCorners(row)).toEqual({
borderTopLeftRadius: 8,
borderBottomRightRadius: 2,
});
});
it("returns no corners for a square row, rather than a zero it never asked for", () => {
expect(resolveHighlightCorners({ backgroundColor: "#111" })).toEqual({});
expect(resolveHighlightCorners(undefined)).toEqual({});
});
it("leaves a style resolved per press state alone", () => {
expect(resolveHighlightCorners(() => ({ borderRadius: 8 }))).toEqual({});
});
});
@@ -1,48 +0,0 @@
import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
/**
* A press highlight is drawn as an overlay inside the pressable rather than as a background on
* it, because the node Reanimated animates cannot also carry the caller's themed style. An
* overlay inherits nothing, so a rounded row gets a square flash unless the corners are copied
* across.
*
* They are copied from the row's own style, not asked for again: the row already declares its
* shape once, and a highlight that had to restate it would be a second copy free to drift. That
* leaves `highlightStyle` to mean only what the highlight is painted with.
*/
const CORNER_PROPERTIES = [
"borderRadius",
"borderTopLeftRadius",
"borderTopRightRadius",
"borderBottomLeftRadius",
"borderBottomRightRadius",
"borderStartStartRadius",
"borderStartEndRadius",
"borderEndStartRadius",
"borderEndEndRadius",
] as const;
/**
* The corner radii a pressable's own style gives its highlight. A style resolved per press state
* is left alone — its shape is not knowable before the press it describes, and every corner it
* could name is one the caller can still set through `highlightStyle`.
*/
export function resolveHighlightCorners(
style: StyleProp<ViewStyle> | ((state: never) => StyleProp<ViewStyle>) | undefined,
): ViewStyle {
if (typeof style === "function") {
return {};
}
const flattened = StyleSheet.flatten(style);
if (!flattened) {
return {};
}
const corners: ViewStyle = {};
for (const property of CORNER_PROPERTIES) {
const value = flattened[property];
if (value !== undefined) {
corners[property] = value;
}
}
return corners;
}
@@ -1,11 +0,0 @@
import { forwardRef } from "react";
import { Pressable } from "react-native";
import type { View } from "react-native";
import type { PressHighlightProps } from "./press-highlight.types";
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{ highlightStyle: _highlightStyle, ...props },
ref,
) {
return <Pressable ref={ref} {...props} />;
});
@@ -1,13 +0,0 @@
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
export interface PressHighlightProps extends PressableProps {
/**
* What the native finger-down surface is painted with — a background colour, an opacity.
* Desktop hover remains owned by the caller.
*
* Shape is not part of it: the highlight takes its corners from the pressable's own `style`, so
* a rounded row gets a rounded flash without restating the radius. Set a corner here only to
* depart from the row's shape on purpose.
*/
highlightStyle?: StyleProp<ViewStyle>;
}
+145 -2
View File
@@ -3,6 +3,8 @@ import {
View,
Text,
TextInput,
Pressable,
Platform,
useWindowDimensions,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
@@ -46,6 +48,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
@@ -160,6 +164,8 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
@@ -219,6 +225,55 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) {
);
}
function AttachmentSheetItem({
item,
onSelect,
}: {
item: AttachmentMenuItem;
onSelect: (item: AttachmentMenuItem) => void;
}) {
const handlePress = useCallback(() => {
onSelect(item);
}, [item, onSelect]);
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.attachmentSheetItem,
pressed && styles.attachmentSheetItemPressed,
item.disabled && styles.buttonDisabled,
],
[item.disabled],
);
return (
<Pressable
testID={`message-input-attachment-menu-item-${item.id}`}
accessibilityRole="button"
disabled={item.disabled}
onPress={handlePress}
style={pressableStyle}
>
{item.icon ? <View style={styles.attachmentSheetItemIcon}>{item.icon}</View> : null}
<Text style={styles.attachmentSheetItemText}>{item.label}</Text>
</Pressable>
);
}
function AttachmentSheetList({
items,
onSelect,
}: {
items: AttachmentMenuItem[];
onSelect: (item: AttachmentMenuItem) => void;
}) {
return (
<View style={styles.attachmentSheetList}>
{items.map((item) => (
<AttachmentSheetItem key={item.id} item={item} onSelect={onSelect} />
))}
</View>
);
}
function AttachmentDropdown({
isConnected,
disabled,
@@ -234,9 +289,73 @@ function AttachmentDropdown({
attachmentMenuItems: AttachmentMenuItem[];
addAttachmentLabel: string;
}) {
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
const isButtonDisabled = !isConnected || disabled;
const attachmentSheetHeader = useMemo<SheetHeader>(
() => ({ title: addAttachmentLabel }),
[addAttachmentLabel],
);
const handleOpenSheet = useCallback(() => {
if (isButtonDisabled) return;
setIsSheetOpen(true);
}, [isButtonDisabled]);
const handleCloseSheet = useCallback(() => {
setIsSheetOpen(false);
}, []);
const handleSheetItemSelect = useCallback((item: AttachmentMenuItem) => {
if (item.disabled) return;
setIsSheetOpen(false);
if (Platform.OS === "ios") {
setTimeout(item.onSelect, 250);
return;
}
item.onSelect();
}, []);
const mobileAttachButtonStyle = useCallback(
(state: { pressed: boolean; hovered?: boolean }) => {
if (typeof attachButtonStyle === "function") {
return attachButtonStyle({ ...state, hovered: Boolean(state.hovered), open: isSheetOpen });
}
return attachButtonStyle;
},
[attachButtonStyle, isSheetOpen],
);
const renderMobileAttachButtonIcon = useCallback(
({ hovered }: { hovered?: boolean }) => renderAttachButtonIcon({ hovered }),
[renderAttachButtonIcon],
);
if (isCompact) {
return (
<>
<Pressable
disabled={isButtonDisabled}
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
onPress={handleOpenSheet}
style={mobileAttachButtonStyle}
>
{renderMobileAttachButtonIcon}
</Pressable>
<AdaptiveModalSheet
header={attachmentSheetHeader}
visible={isSheetOpen}
onClose={handleCloseSheet}
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
testID="message-input-attachment-menu"
>
<AttachmentSheetList items={attachmentMenuItems} onSelect={handleSheetItemSelect} />
</AdaptiveModalSheet>
</>
);
}
return (
<DropdownMenu compactMode="sheet">
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
@@ -259,7 +378,6 @@ function AttachmentDropdown({
offset={8}
minWidth={220}
testID="message-input-attachment-menu"
sheetTitle={addAttachmentLabel}
>
<AttachmentMenuList items={attachmentMenuItems} />
</DropdownMenuContent>
@@ -1823,6 +1941,31 @@ const styles = StyleSheet.create((theme: Theme) => ({
buttonDisabled: {
opacity: 0.5,
},
attachmentSheetList: {
gap: theme.spacing[1],
},
attachmentSheetItem: {
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
attachmentSheetItemPressed: {
backgroundColor: theme.colors.surface2,
},
attachmentSheetItemIcon: {
width: 20,
alignItems: "center",
justifyContent: "center",
},
attachmentSheetItemText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
overlayContainer: {
position: "absolute",
display: "flex",
@@ -2,6 +2,3 @@ import Constants from "expo-constants";
/** F-Droid build without proprietary camera or notification dependencies. */
export const isFdroidBuild = Constants.expoConfig?.extra?.fdroidBuild === true;
/** Production-like Android build with local profiling enabled. */
export const isProfileBuild = Constants.expoConfig?.extra?.profileBuild === true;
@@ -10,6 +10,7 @@ import {
createSetAgentInitializing,
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
createSessionAgentStreamReducerQueue,
@@ -493,6 +494,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
if (!isConnected) {
return;
}
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo?.features?.providersSnapshot) {
return;
}
prefetchProvidersSnapshot(serverId, client);
}, [client, isConnected, serverId]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
@@ -725,6 +739,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
viewedTimelineSyncRef.current?.setConnected(isConnected);
}, [isConnected]);
useEffect(
() =>
getHostRuntimeStore().subscribeAgentStoppedRunning(serverId, (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
if (timeline.status === "synced" && timeline.newer === "available") {
return;
}
viewedTimelineSyncRef.current?.reconcileAgent(agentId);
}),
[serverId],
);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
function createStorage() {
const values = new Map<string, string>();
return {
values,
async getItem(key: string) {
return values.get(key) ?? null;
},
async setItem(key: string, value: string) {
values.set(key, value);
},
async removeItem(key: string) {
values.delete(key);
},
};
}
describe("provider snapshot cache", () => {
it("round-trips compact snapshots with shared thinking option references", async () => {
const thinkingOptions = [{ id: "high", label: "High", isDefault: true }];
const entries: ProviderSnapshotEntry[] = [
{
provider: "pi",
status: "ready",
enabled: true,
models: ["one", "two"].map((id) => ({
provider: "pi",
id,
label: id,
thinkingOptions,
defaultThinkingOptionId: "high",
})),
},
];
const storage = createStorage();
const cache = createProviderSnapshotCache(storage);
await cache.write({
serverId: "server-1",
cwd: "/repo",
hash: "snapshot-hash",
generatedAt: "2026-08-04T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(entries),
});
const cached = await cache.read("server-1", "/repo");
expect(cached?.entries).toEqual(entries);
expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe(
cached?.entries[0]?.models?.[1]?.thinkingOptions,
);
});
it("discards an invalid cache record", async () => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage);
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json");
await expect(cache.read("server-1", "/repo")).resolves.toBeNull();
expect(storage.values.size).toBe(0);
});
});
@@ -1,129 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import {
expandProviderSnapshot,
type CompactProviderSnapshot,
} from "@getpaseo/protocol/provider-snapshot-codec";
import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages";
const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1";
interface ProviderSnapshotStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
interface StoredProviderSnapshot {
version: typeof CACHE_VERSION;
hash: string;
generatedAt: string;
compactSnapshot: CompactProviderSnapshot;
}
export interface CachedProviderSnapshot extends StoredProviderSnapshot {
entries: ProviderSnapshotEntry[];
}
export interface ProviderSnapshotCache {
read(serverId: string, cwd: string | null): Promise<CachedProviderSnapshot | null>;
write(input: {
serverId: string;
cwd: string | null;
hash: string;
generatedAt: string;
compactSnapshot: CompactProviderSnapshot;
}): Promise<void>;
}
export class ProviderSnapshotCacheMissError extends Error {
constructor() {
super("Daemon returned not-modified without a cached provider snapshot");
this.name = "ProviderSnapshotCacheMissError";
}
}
function cacheKey(serverId: string, cwd: string | null): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`;
}
function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) return null;
const version = Reflect.get(parsed, "version");
const hash = Reflect.get(parsed, "hash");
const generatedAt = Reflect.get(parsed, "generatedAt");
const compactSnapshot = CompactProviderSnapshotSchema.safeParse(
Reflect.get(parsed, "compactSnapshot"),
);
if (
version !== CACHE_VERSION ||
typeof hash !== "string" ||
typeof generatedAt !== "string" ||
!compactSnapshot.success
) {
return null;
}
return {
version,
hash,
generatedAt,
compactSnapshot: compactSnapshot.data,
};
}
async function discardCachedSnapshot(storage: ProviderSnapshotStorage, key: string): Promise<void> {
try {
await storage.removeItem(key);
} catch (error) {
if (!(error instanceof Error)) throw error;
}
}
export function createProviderSnapshotCache(
storage: ProviderSnapshotStorage,
): ProviderSnapshotCache {
return {
async read(serverId, cwd) {
const key = cacheKey(serverId, cwd);
let value: string | null;
try {
value = await storage.getItem(key);
} catch (error) {
if (error instanceof Error) return null;
throw error;
}
if (value === null) return null;
try {
const stored = parseStoredProviderSnapshot(value);
if (!stored) {
await discardCachedSnapshot(storage, key);
return null;
}
return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) };
} catch (error) {
if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) {
throw error;
}
await discardCachedSnapshot(storage, key);
return null;
}
},
async write(input) {
const stored: StoredProviderSnapshot = {
version: CACHE_VERSION,
hash: input.hash,
generatedAt: input.generatedAt,
compactSnapshot: input.compactSnapshot,
};
try {
await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored));
} catch (error) {
if (!(error instanceof Error)) throw error;
}
},
};
}
export const providerSnapshotCache = createProviderSnapshotCache(AsyncStorage);
@@ -21,13 +21,11 @@ export function providersSnapshotQueryKey(serverId: string | null, cwd?: string
export function providersSnapshotRequestOptions(input: {
cwd?: string | null;
providers?: AgentProvider[];
ifNoneMatch?: string;
}) {
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
return {
...(normalizedCwd ? { cwd: normalizedCwd } : {}),
...(input.providers ? { providers: input.providers } : {}),
...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
};
}
+1 -17
View File
@@ -8,12 +8,7 @@ import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import {
normalizeProvidersSnapshotCwd,
providersSnapshotQueryKey,
providersSnapshotQueryRoot,
} from "@/data/providers-snapshot";
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
@@ -189,7 +184,6 @@ export function applyProvidersSnapshotUpdate(input: {
serverId: string;
queryClient: QueryClient;
message: ProvidersSnapshotUpdate;
cache?: ProviderSnapshotCache;
}): void {
if (input.message.type !== "providers_snapshot_update") {
return;
@@ -200,16 +194,6 @@ export function applyProvidersSnapshotUpdate(input: {
generatedAt: input.message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
const { compactSnapshot, snapshotHash } = input.message.payload;
if (compactSnapshot && snapshotHash) {
void (input.cache ?? providerSnapshotCache).write({
serverId: input.serverId,
cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd),
hash: snapshotHash,
generatedAt: input.message.payload.generatedAt,
compactSnapshot,
});
}
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
exact: false,
@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
import {
@@ -81,21 +79,6 @@ function codexEntry(
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
const serverId = "server-1";
function createCache(initial: CachedProviderSnapshot | null = null): ProviderSnapshotCache & {
writes: Parameters<ProviderSnapshotCache["write"]>[0][];
} {
const writes: Parameters<ProviderSnapshotCache["write"]>[0][] = [];
return {
writes,
async read() {
return initial;
},
async write(input) {
writes.push(input);
},
};
}
describe("providersSnapshotQueryKey", () => {
it("uses separate keys for home and workspace scopes", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]);
@@ -112,7 +95,7 @@ describe("fetchProvidersSnapshot", () => {
it("sends no cwd for the home scope", async () => {
const client = createClient({ snapshots: [providersSnapshot([])] });
await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() });
await fetchProvidersSnapshot({ client, cwd: null });
expect(client.getCalls).toEqual([{}]);
});
@@ -120,78 +103,10 @@ describe("fetchProvidersSnapshot", () => {
it("sends the workspace cwd for the workspace scope", async () => {
const client = createClient({ snapshots: [providersSnapshot([])] });
await fetchProvidersSnapshot({
client,
serverId,
cwd: "/repo-a",
cache: createCache(),
});
await fetchProvidersSnapshot({ client, cwd: "/repo-a" });
expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]);
});
it("reuses a cached snapshot when the daemon reports its hash unchanged", async () => {
const entries = [codexEntry("ready", [readyCodexModel])];
const compactSnapshot = compactProviderSnapshot(entries);
const cache = createCache({
version: 1,
hash: "snapshot-hash",
generatedAt: "2026-01-01T00:00:00.000Z",
compactSnapshot,
entries,
});
const client = createClient({
snapshots: [
{
entries: [],
snapshotHash: "snapshot-hash",
notModified: true,
generatedAt: "2026-01-02T00:00:00.000Z",
requestId: "snapshot-2",
},
],
});
const snapshot = await fetchProvidersSnapshot({
client,
serverId,
cwd: "/repo-a",
cache,
});
expect(client.getCalls).toEqual([{ cwd: "/repo-a", ifNoneMatch: "snapshot-hash" }]);
expect(snapshot.entries).toBe(entries);
expect(cache.writes).toEqual([]);
});
it("persists a changed compact snapshot for the next launch", async () => {
const entries = [codexEntry("ready", [readyCodexModel])];
const compactSnapshot = compactProviderSnapshot(entries);
const cache = createCache();
const client = createClient({
snapshots: [
{
entries,
compactSnapshot,
snapshotHash: "next-hash",
generatedAt: "2026-01-02T00:00:00.000Z",
requestId: "snapshot-2",
},
],
});
await fetchProvidersSnapshot({ client, serverId, cwd: "/repo-a", cache });
expect(cache.writes).toEqual([
{
serverId,
cwd: "/repo-a",
hash: "next-hash",
generatedAt: "2026-01-02T00:00:00.000Z",
compactSnapshot,
},
]);
});
});
describe("refreshAndApplyProvidersSnapshot", () => {
@@ -212,7 +127,6 @@ describe("refreshAndApplyProvidersSnapshot", () => {
serverId,
cwd: null,
providers: ["codex"],
cache: createCache(),
});
expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]);
@@ -233,7 +147,6 @@ describe("refreshAndApplyProvidersSnapshot", () => {
serverId,
cwd: "/repo-a",
providers: ["codex"],
cache: createCache(),
});
expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]);
@@ -253,7 +166,6 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: null,
cache: createCache(),
});
expect(
@@ -274,7 +186,6 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: "/repo-a",
cache: createCache(),
});
expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe(
@@ -298,7 +209,6 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: "/repo-a",
cache: createCache(),
});
expect(queryClient.getQueryState(commandsKey)?.isInvalidated).toBe(true);
@@ -356,27 +266,6 @@ describe("applyProvidersSnapshotUpdate", () => {
);
});
it("persists compact push updates", () => {
const entries = [codexEntry("ready", [readyCodexModel])];
const compactSnapshot = compactProviderSnapshot(entries);
const cache = createCache();
const message = updateMessage(entries, "/repo-a");
message.payload.compactSnapshot = compactSnapshot;
message.payload.snapshotHash = "push-hash";
applyProvidersSnapshotUpdate({ serverId, queryClient, message, cache });
expect(cache.writes).toEqual([
{
serverId,
cwd: "/repo-a",
hash: "push-hash",
generatedAt: "2026-01-01T00:00:01.000Z",
compactSnapshot,
},
]);
});
it("applies Windows daemon updates to app-normalized workspace paths", () => {
const workspaceCwd = "C:/Users/Ezekiel Bulver/project";
const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project";
@@ -8,11 +8,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store";
import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import {
providerSnapshotCache,
ProviderSnapshotCacheMissError,
type ProviderSnapshotCache,
} from "@/data/provider-snapshot-cache";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import {
isProvidersSnapshotHomeScope,
@@ -34,31 +29,9 @@ export type ProvidersSnapshotClient = Pick<
export async function fetchProvidersSnapshot(input: {
client: ProvidersSnapshotClient;
serverId: string;
cwd: string | null;
cache?: ProviderSnapshotCache;
}): Promise<GetProvidersSnapshotResult> {
const cache = input.cache ?? providerSnapshotCache;
const cached = await cache.read(input.serverId, input.cwd);
const snapshot = await input.client.getProvidersSnapshot(
providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }),
);
if (snapshot.notModified) {
if (!cached) {
throw new ProviderSnapshotCacheMissError();
}
return { ...snapshot, entries: cached.entries };
}
if (snapshot.compactSnapshot && snapshot.snapshotHash) {
await cache.write({
serverId: input.serverId,
cwd: input.cwd,
hash: snapshot.snapshotHash,
generatedAt: snapshot.generatedAt,
compactSnapshot: snapshot.compactSnapshot,
});
}
return snapshot;
return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd }));
}
export async function refreshAndApplyProvidersSnapshot(input: {
@@ -67,17 +40,11 @@ export async function refreshAndApplyProvidersSnapshot(input: {
serverId: string;
cwd: string | null;
providers?: AgentProvider[];
cache?: ProviderSnapshotCache;
}): Promise<RefreshProvidersSnapshotResult> {
const refreshResult = await input.client.refreshProvidersSnapshot(
providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }),
);
const snapshot = await fetchProvidersSnapshot({
client: input.client,
serverId: input.serverId,
cwd: input.cwd,
cache: input.cache,
});
const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd });
input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot);
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
@@ -146,10 +113,10 @@ export function useProvidersSnapshot(
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
pushEvent: "providers_snapshot_update",
queryFn: async () => {
if (!client || !serverId) {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchProvidersSnapshot({ client, serverId, cwd });
return fetchProvidersSnapshot({ client, cwd });
},
});
@@ -213,6 +180,6 @@ export function prefetchProvidersSnapshot(
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: Infinity,
queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }),
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
});
}
-72
View File
@@ -1,72 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Server } from "lucide-react-native";
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
import { identityColor } from "@/styles/identity-colors";
import type { Theme } from "@/styles/theme";
/**
* The glyph size the badge draws at. Rows that place the badge alongside their own glyphs
* match this so the line reads as one rank of peers.
*/
export const HOST_BADGE_ICON_SIZE = 12;
const ThemedServer = withUnistyles(Server);
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
// Theme-independent, so the mapping per host color is fixed for the life of the module. A
// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass.
const HOST_ICON_MAPPINGS: Record<HostColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) });
}
return byColor;
})();
/**
* Which machine something lives on, drawn the same way everywhere it appears: a server glyph
* in the host's identity color, and the host's name when the host is configured to show one.
*
* A hostname is the least interesting thing on any line that carries it and the only one whose
* length nobody chose, so the badge yields space before its neighbours rather than alongside
* them — see `flexShrink` below.
*/
export function HostBadge({ badge }: { badge: HostBadgeModel }) {
return (
<View
style={styles.badge}
testID={`host-badge-${badge.serverId}`}
accessibilityLabel={badge.label}
>
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
{badge.showLabel ? (
<Text style={styles.label} numberOfLines={1}>
{badge.label}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
// Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully
// squeezed before that sibling gives up its first pixel. An equal factor would split the
// loss in proportion to length, which hands the most space to the longest hostname —
// exactly backwards. The icon has a fixed width, so the badge never disappears outright.
flexShrink: 100,
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 1,
minWidth: 0,
},
}));
-30
View File
@@ -1,30 +0,0 @@
import { useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
/**
* Every host's badge, resolved from the three things that decide one: the host registry, which
* host is local, and each host's own appearance. `enabled` is the caller's own "off" — a surface
* that has its own reason to hide badges passes false rather than filtering the result, so the
* per-host setting stays the only thing that decides name vs icon vs hidden.
*/
export function useHostBadges({
enabled,
}: {
enabled: boolean;
}): ReadonlyMap<string, HostBadgeModel> {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const localDaemon = useLocalDaemonServerIdState();
return useMemo(
() =>
selectHostBadges({
hosts,
localServerId,
localHostResolutionPending: localDaemon.status !== "resolved",
enabled,
}),
[hosts, localDaemon.status, localServerId, enabled],
);
}
@@ -1,38 +0,0 @@
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
import { requireOptionalNativeModule } from "expo-modules-core";
import { isProfileBuild } from "@/constants/build-profile";
interface PaseoNativeTraceModule {
beginSection(name: string): void;
endSection(): void;
}
const traceModule = requireOptionalNativeModule<PaseoNativeTraceModule>("PaseoNativeTrace");
export const nativePerformanceTrace: DaemonClientTrace = {
isEnabled() {
return isProfileBuild && traceModule !== null;
},
beginSection(name, args) {
traceModule?.beginSection(formatSectionName(name, args));
},
endSection() {
traceModule?.endSection();
},
};
function formatSectionName(name: string, args?: Record<string, string>): string {
if (!args) {
return name;
}
const fields = Object.entries(args).map(([key, value]) => `${key}=${value}`);
return `${name}|${fields.join(";")}`;
}
export function traceInstant(name: string, args?: Record<string, string>): void {
if (!nativePerformanceTrace.isEnabled()) {
return;
}
nativePerformanceTrace.beginSection(name, args);
nativePerformanceTrace.endSection();
}
+23 -5
View File
@@ -56,7 +56,6 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -484,7 +483,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
capabilities: appCapabilities,
trace: nativePerformanceTrace,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
@@ -523,7 +521,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...(host.serverId ? { serverId: host.serverId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: nativePerformanceTrace,
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
@@ -1344,6 +1341,7 @@ interface AgentDirectoryRefreshInput {
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private serverListeners = new Map<string, Set<() => void>>();
private agentStoppedRunningListeners = new Map<string, Set<(agentId: string) => void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
private version = 0;
@@ -1591,11 +1589,12 @@ export class HostRuntimeStore {
rekeyMap(this.controllers, oldServerId, newServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
rekeyMap(this.agentStoppedRunningListeners, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -1978,7 +1977,7 @@ export class HostRuntimeStore {
this.directorySyncByServer.set(
host.serverId,
new DirectorySync(host.serverId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -2177,6 +2176,18 @@ export class HostRuntimeStore {
};
}
subscribeAgentStoppedRunning(serverId: string, listener: (agentId: string) => void): () => void {
const listeners = this.agentStoppedRunningListeners.get(serverId) ?? new Set();
listeners.add(listener);
this.agentStoppedRunningListeners.set(serverId, listeners);
return () => {
const current = this.agentStoppedRunningListeners.get(serverId);
if (!current) return;
current.delete(listener);
if (current.size === 0) this.agentStoppedRunningListeners.delete(serverId);
};
}
subscribeAll(listener: () => void): () => void {
this.globalListeners.add(listener);
return () => {
@@ -2285,6 +2296,13 @@ export class HostRuntimeStore {
listener();
}
}
private onAgentStoppedRunning(serverId: string, agentId: string): void {
this.drainQueuedAgentMessage(serverId, agentId);
for (const listener of this.agentStoppedRunningListeners.get(serverId) ?? []) {
listener(agentId);
}
}
}
let singletonHostRuntimeStore: HostRuntimeStore | null = null;
@@ -1359,9 +1359,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
style={badgePressableStyle}
testID="host-picker-trigger"
>
<View style={styles.badgeIconBox}>
<HostStatusDot serverId={host.selectedServerId} />
</View>
<HostStatusDot serverId={host.selectedServerId} />
<Text style={styles.badgeText} numberOfLines={1}>
{selectedHostLabel}
</Text>
@@ -11,12 +11,7 @@ type WorkspaceHeaderRenderState =
kind: "ready";
title: string;
subtitle: string;
/**
* Whether the project name says anything the workspace name doesn't. It is the fact, not
* the verdict: a header that stacks the two lines shows the project either way, and one
* that sets them side by side drops the repeat.
*/
isSubtitleDistinct: boolean;
shouldShowSubtitle: boolean;
isGitCheckout: boolean;
currentBranchName: string | null;
};
@@ -70,7 +65,7 @@ export function resolveWorkspaceHeaderRenderState(input: {
kind: "ready",
title: header.title,
subtitle: header.subtitle,
isSubtitleDistinct: !areHeaderLabelsEquivalent(header.title, header.subtitle),
shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle),
isGitCheckout: checkout?.isGit ?? false,
currentBranchName,
};
@@ -26,6 +26,7 @@ import {
ChevronDown,
Copy,
Ellipsis,
EllipsisVertical,
Globe,
Import as ImportIcon,
PanelRight,
@@ -44,8 +45,6 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -74,7 +73,6 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
collectAllTabs,
@@ -246,6 +244,7 @@ function buildWorkspaceFileLocation(
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -939,12 +938,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
recoveryAgentId,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
useEffect(() => {
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
return () => {
traceInstant("paseo.workspace.unmount", { serverId, workspaceId });
};
}, [serverId, workspaceId]);
return (
<WorkspaceScreenContent
serverId={serverId}
@@ -1050,16 +1043,18 @@ function HeaderMenuProfileItem({
);
}
/**
* The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against
* each other — so the slop can only grow vertically. Widening it would put two buttons' slop over
* the same pixels, which is a worse miss than a small target.
*/
const COMPACT_HEADER_BUTTON_HIT_SLOP = { top: 8, bottom: 8 } as const;
function WorkspaceHeaderMenuTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) {
function WorkspaceHeaderMenuTriggerIcon({
hovered,
open,
isMobile,
}: {
hovered: boolean;
open: boolean;
isMobile: boolean;
}) {
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
return <ThemedEllipsis size={16} uniProps={colorMapping} />;
return <Icon size={16} uniProps={colorMapping} />;
}
function WorkspaceHeaderMenu({
@@ -1100,28 +1095,22 @@ function WorkspaceHeaderMenu({
const renderTriggerIcon = useCallback(
({ hovered, open }: { hovered: boolean; open: boolean }) => (
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} />
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} />
),
[],
[isMobile],
);
return (
<DropdownMenu compactMode="sheet">
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={isMobile ? styles.compactHeaderActionButton : styles.headerActionButton}
hitSlop={isMobile ? COMPACT_HEADER_BUTTON_HIT_SLOP : undefined}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={220}
testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
<DropdownMenuContent align="start" width={220} testID="workspace-header-menu">
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={menuNewAgentIcon}
@@ -1205,52 +1194,11 @@ function WorkspaceHeaderMenu({
);
}
/**
* Which project the workspace belongs to, and which machine it runs on.
*
* Compact gets both, on their own line under the workspace name: this header is the only thing on
* screen that says where the workspace lives, because the sidebar that normally carries the host
* badge is closed. It still follows the host's own badge setting, so a purely local setup stays
* quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit
* side by side, and kept on compact, where the line exists for the host anyway.
*/
function WorkspaceHeaderProjectRow({
subtitle,
isSubtitleDistinct,
serverId,
}: {
subtitle: string;
isSubtitleDistinct: boolean;
serverId: string;
}) {
const isCompact = useIsCompactFormFactor();
const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null;
const showProject = isSubtitleDistinct || isCompact;
if (!showProject && !hostBadge) {
return null;
}
return (
<View style={styles.headerProjectRow}>
{showProject ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.headerProjectSeparator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
interface WorkspaceHeaderTitleBarProps {
isLoading: boolean;
title: string;
subtitle: string;
isSubtitleDistinct: boolean;
showSubtitle: boolean;
currentBranchName: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -1285,7 +1233,7 @@ function WorkspaceHeaderTitleBar({
isLoading,
title,
subtitle,
isSubtitleDistinct,
showSubtitle,
currentBranchName,
normalizedServerId,
normalizedWorkspaceId,
@@ -1323,14 +1271,16 @@ function WorkspaceHeaderTitleBar({
</View>
) : (
<View style={styles.headerTitleTextGroup}>
<ScreenTitle testID="workspace-header-title" style={styles.headerTitle}>
{title}
</ScreenTitle>
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={normalizedServerId}
/>
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
{showSubtitle ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
@@ -1460,7 +1410,7 @@ interface WorkspaceHeaderFields {
isWorkspaceHeaderLoading: boolean;
workspaceHeaderTitle: string;
workspaceHeaderSubtitle: string;
isWorkspaceHeaderSubtitleDistinct: boolean;
shouldShowWorkspaceHeaderSubtitle: boolean;
isGitCheckout: boolean;
currentBranchName: string | null;
}
@@ -1495,7 +1445,7 @@ function deriveWorkspaceHeaderFields(input: {
isWorkspaceHeaderLoading: true,
workspaceHeaderTitle: "",
workspaceHeaderSubtitle: "",
isWorkspaceHeaderSubtitleDistinct: false,
shouldShowWorkspaceHeaderSubtitle: false,
isGitCheckout: false,
currentBranchName: null,
};
@@ -1504,7 +1454,7 @@ function deriveWorkspaceHeaderFields(input: {
isWorkspaceHeaderLoading: false,
workspaceHeaderTitle: renderState.title,
workspaceHeaderSubtitle: renderState.subtitle,
isWorkspaceHeaderSubtitleDistinct: renderState.isSubtitleDistinct,
shouldShowWorkspaceHeaderSubtitle: renderState.shouldShowSubtitle,
isGitCheckout: renderState.isGitCheckout,
currentBranchName: renderState.currentBranchName,
};
@@ -1979,7 +1929,7 @@ function WorkspaceScreenContent({
isWorkspaceHeaderLoading,
workspaceHeaderTitle,
workspaceHeaderSubtitle,
isWorkspaceHeaderSubtitleDistinct,
shouldShowWorkspaceHeaderSubtitle,
isGitCheckout,
currentBranchName,
} = deriveWorkspaceHeaderFields({
@@ -3810,7 +3760,7 @@ function WorkspaceScreenContent({
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -3975,14 +3925,14 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
// Compact steps the whole header down one rung of the scale. The title and the project line
// stack there, so at the shared size they read as two headings rather than a subject and its
// caption, and they cost two full lines of a header that has none to spare.
headerTitle: {
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
@@ -4018,29 +3968,15 @@ const styles = StyleSheet.create((theme) => ({
md: theme.spacing[2],
},
},
// No width cap. A percentage cap resolves against the title group, whose own width comes from
// this row's content, so it clips the project name while there is still room beside it.
// `flexShrink` on both this row and the title already gives up space only when there is none.
headerProjectRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
flexShrink: 1,
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.xs,
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1,
minWidth: 0,
},
headerProjectSeparator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
maxWidth: "60%",
},
headerTitleSkeleton: {
width: 220,
@@ -105,7 +105,7 @@ describe("workspace source of truth consumption", () => {
kind: "ready",
title: "feat/workspace-sot",
subtitle: "getpaseo/paseo",
isSubtitleDistinct: true,
shouldShowSubtitle: true,
isGitCheckout: false,
currentBranchName: null,
});
@@ -126,7 +126,7 @@ describe("workspace source of truth consumption", () => {
kind: "ready",
title: "notes",
subtitle: "Local folders",
isSubtitleDistinct: true,
shouldShowSubtitle: true,
isGitCheckout: false,
currentBranchName: null,
});
@@ -145,7 +145,7 @@ describe("workspace source of truth consumption", () => {
kind: "ready",
title: "feat/workspace-sot",
subtitle: "getpaseo/paseo",
isSubtitleDistinct: true,
shouldShowSubtitle: true,
isGitCheckout: true,
currentBranchName: "feat/workspace-sot",
});
@@ -169,7 +169,7 @@ describe("workspace source of truth consumption", () => {
kind: "ready",
title: "notes",
subtitle: "notes",
isSubtitleDistinct: false,
shouldShowSubtitle: false,
isGitCheckout: false,
currentBranchName: null,
});
@@ -185,7 +185,7 @@ describe("workspace source of truth consumption", () => {
kind: "ready",
title: "feat/workspace-sot",
subtitle: "getpaseo/paseo",
isSubtitleDistinct: true,
shouldShowSubtitle: true,
isGitCheckout: false,
currentBranchName: null,
});
@@ -39,7 +39,6 @@ const FLAG_STRIKETHROUGH = 1 << 5;
interface ResolvedCellStyle {
key: string;
style: TextStyle;
foregroundColor: string;
}
interface ResolvedCellColors {
@@ -57,11 +56,6 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: "#ffff00",
foreground: "#000000",
} as const;
function build256Palette(theme: ITheme): string[] {
const palette = Array.from({ length: 256 }, () => "");
@@ -228,7 +222,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
});
const cachedStyle = styleCache.get(key);
if (cachedStyle) {
return { key, style: cachedStyle, foregroundColor: colors.foreground };
return { key, style: cachedStyle };
}
const style = createTextStyle({
@@ -238,7 +232,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
flags,
});
styleCache.set(key, style);
return { key, style, foregroundColor: colors.foreground };
return { key, style };
},
};
}
@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph";
describe("native terminal custom glyphs", () => {
it("fills the complete cell for the full block used by the Claude mascot", () => {
expect(resolveTerminalCustomGlyph("█")).toEqual({
kind: "rects",
rects: [{ x: 0, y: 0, width: 1, height: 1 }],
});
});
it("draws every Claude mascot block from exact cell fractions", () => {
const mascot = ["▐▛███▜▌", "▝▜█████▛▘", "▘▘ ▝▝"];
const customGlyphs = mascot
.join("")
.replaceAll(" ", "")
.split("")
.map(resolveTerminalCustomGlyph);
expect(customGlyphs).not.toContain(null);
expect(resolveTerminalCustomGlyph("▐")).toEqual({
kind: "rects",
rects: [{ x: 0.5, y: 0, width: 0.5, height: 1 }],
});
expect(resolveTerminalCustomGlyph("▛")).toEqual({
kind: "rects",
rects: [
{ x: 0, y: 0, width: 0.5, height: 0.5 },
{ x: 0.5, y: 0, width: 0.5, height: 0.5 },
{ x: 0, y: 0.5, width: 0.5, height: 0.5 },
],
});
expect(resolveTerminalCustomGlyph("▝")).toEqual({
kind: "rects",
rects: [{ x: 0.5, y: 0, width: 0.5, height: 0.5 }],
});
});
it("connects the rounded Claude welcome frame to every cell edge", () => {
expect(resolveTerminalCustomGlyph("─")).toEqual({
kind: "path",
path: "M0,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("│")).toEqual({
kind: "path",
path: "M.5,0 L.5,1",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╭")).toEqual({
kind: "path",
path: "M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╮")).toEqual({
kind: "path",
path: "M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╰")).toEqual({
kind: "path",
path: "M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╯")).toEqual({
kind: "path",
path: "M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0",
strokeWidth: 1,
});
});
it("keeps ordinary terminal text on the font renderer", () => {
expect(resolveTerminalCustomGlyph("C")).toBeNull();
expect(resolveTerminalCustomGlyph(" ")).toBeNull();
});
it("covers common light and heavy terminal ASCII-art frames", () => {
const frames = [
"┌────┬────┐│ │ │├────┼────┤└────┴────┘",
"┏━━━━┳━━━━┓┃ ┃ ┃┣━━━━╋━━━━┫┗━━━━┻━━━━┛",
];
for (const char of frames.join("").replaceAll(" ", "")) {
expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull();
}
});
});
@@ -1,119 +0,0 @@
export interface TerminalGlyphRect {
x: number;
y: number;
width: number;
height: number;
}
export interface TerminalRectGlyph {
kind: "rects";
rects: readonly TerminalGlyphRect[];
}
export interface TerminalPathGlyph {
kind: "path";
path: string;
strokeWidth: number;
}
export type TerminalCustomGlyph = TerminalRectGlyph | TerminalPathGlyph;
const LIGHT_BOX_GLYPHS: Readonly<Record<string, TerminalPathGlyph>> = {
"─": path("M0,.5 L1,.5"),
"│": path("M.5,0 L.5,1"),
"┌": path("M.5,1 L.5,.5 L1,.5"),
"┐": path("M0,.5 L.5,.5 L.5,1"),
"└": path("M.5,0 L.5,.5 L1,.5"),
"┘": path("M0,.5 L.5,.5 L.5,0"),
"├": path("M.5,0 L.5,1 M.5,.5 L1,.5"),
"┤": path("M.5,0 L.5,1 M.5,.5 L0,.5"),
"┬": path("M0,.5 L1,.5 M.5,.5 L.5,1"),
"┴": path("M0,.5 L1,.5 M.5,.5 L.5,0"),
"┼": path("M0,.5 L1,.5 M.5,0 L.5,1"),
"╭": path("M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5"),
"╮": path("M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1"),
"╰": path("M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5"),
"╯": path("M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0"),
};
const HEAVY_BOX_GLYPHS: Readonly<Record<string, TerminalPathGlyph>> = {
"━": path("M0,.5 L1,.5", 3),
"┃": path("M.5,0 L.5,1", 3),
"┏": path("M.5,1 L.5,.5 L1,.5", 3),
"┓": path("M0,.5 L.5,.5 L.5,1", 3),
"┗": path("M.5,0 L.5,.5 L1,.5", 3),
"┛": path("M0,.5 L.5,.5 L.5,0", 3),
"┣": path("M.5,0 L.5,1 M.5,.5 L1,.5", 3),
"┫": path("M.5,0 L.5,1 M.5,.5 L0,.5", 3),
"┳": path("M0,.5 L1,.5 M.5,.5 L.5,1", 3),
"┻": path("M0,.5 L1,.5 M.5,.5 L.5,0", 3),
"╋": path("M0,.5 L1,.5 M.5,0 L.5,1", 3),
};
const BLOCK_GLYPHS: Readonly<Record<string, TerminalRectGlyph>> = {
"▀": rects(rect(0, 0, 1, 0.5)),
"▁": rects(rect(0, 0.875, 1, 0.125)),
"▂": rects(rect(0, 0.75, 1, 0.25)),
"▃": rects(rect(0, 0.625, 1, 0.375)),
"▄": rects(rect(0, 0.5, 1, 0.5)),
"▅": rects(rect(0, 0.375, 1, 0.625)),
"▆": rects(rect(0, 0.25, 1, 0.75)),
"▇": rects(rect(0, 0.125, 1, 0.875)),
"█": rects(rect(0, 0, 1, 1)),
"▉": rects(rect(0, 0, 0.875, 1)),
"▊": rects(rect(0, 0, 0.75, 1)),
"▋": rects(rect(0, 0, 0.625, 1)),
"▌": rects(rect(0, 0, 0.5, 1)),
"▍": rects(rect(0, 0, 0.375, 1)),
"▎": rects(rect(0, 0, 0.25, 1)),
"▏": rects(rect(0, 0, 0.125, 1)),
"▐": rects(rect(0.5, 0, 0.5, 1)),
"▔": rects(rect(0, 0, 1, 0.125)),
"▕": rects(rect(0.875, 0, 0.125, 1)),
"▖": rects(lowerLeft()),
"▗": rects(lowerRight()),
"▘": rects(upperLeft()),
"▙": rects(upperLeft(), lowerLeft(), lowerRight()),
"▚": rects(upperLeft(), lowerRight()),
"▛": rects(upperLeft(), upperRight(), lowerLeft()),
"▜": rects(upperLeft(), upperRight(), lowerRight()),
"▝": rects(upperRight()),
"▞": rects(upperRight(), lowerLeft()),
"▟": rects(upperRight(), lowerLeft(), lowerRight()),
};
export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null {
return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null;
}
export function isTerminalCustomGlyph(char: string): boolean {
return resolveTerminalCustomGlyph(char) !== null;
}
function path(pathData: string, strokeWidth = 1): TerminalPathGlyph {
return { kind: "path", path: pathData, strokeWidth };
}
function rect(x: number, y: number, width: number, height: number): TerminalGlyphRect {
return { x, y, width, height };
}
function rects(...values: TerminalGlyphRect[]): TerminalRectGlyph {
return { kind: "rects", rects: values };
}
function upperLeft(): TerminalGlyphRect {
return rect(0, 0, 0.5, 0.5);
}
function upperRight(): TerminalGlyphRect {
return rect(0.5, 0, 0.5, 0.5);
}
function lowerLeft(): TerminalGlyphRect {
return rect(0, 0.5, 0.5, 0.5);
}
function lowerRight(): TerminalGlyphRect {
return rect(0.5, 0.5, 0.5, 0.5);
}
@@ -1,23 +1,12 @@
import { describe, expect, it } from "vitest";
import {
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveMeasuredTerminalCellMetrics,
resolveTerminalCursorOffset,
} from "./terminal-grid-metrics";
describe("native terminal grid metrics", () => {
it("places each normalized custom glyph in run viewport coordinates", () => {
expect(
resolveTerminalCustomGlyphCellTransform({
cellOffset: 57,
cellWidth: 7.4,
cellHeight: 16.2,
}),
).toBe("matrix(7.4 0 0 16.2 421.8 0)");
});
it("preserves fractional measured cell width for horizontal layout", () => {
expect(
resolveMeasuredTerminalCellMetrics({
@@ -21,19 +21,6 @@ export interface TerminalCursorOffset {
y: number;
}
export interface TerminalCustomGlyphCellTransformInput {
cellOffset: number;
cellWidth: number;
cellHeight: number;
}
export function resolveTerminalCustomGlyphCellTransform(
input: TerminalCustomGlyphCellTransformInput,
): string {
const translateX = input.cellOffset * input.cellWidth;
return `matrix(${input.cellWidth} 0 0 ${input.cellHeight} ${translateX} 0)`;
}
export function resolveMeasuredTerminalCellMetrics(
input: TerminalGridCellMetricsInput,
): TerminalGridCellMetrics {
@@ -9,32 +9,23 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Svg, { G, Path } from "react-native-svg";
import type { ITheme } from "@xterm/xterm";
import {
createTerminalCellStyleResolver,
DEFAULT_TERMINAL_THEME,
NATIVE_TERMINAL_SELECTION_COLORS,
} from "./colors";
import type { TerminalGlyphRect } from "./terminal-custom-glyph";
import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors";
import { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state";
import {
resolveMeasuredTerminalCellMetrics,
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveTerminalCursorOffset,
type TerminalGridCellMetrics,
} from "./terminal-grid-metrics";
import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model";
import {
buildRows,
type TerminalCustomGlyphCell,
type TerminalCustomGlyphRun,
type TerminalRowModel,
type TerminalRun,
} from "./terminal-row-model";
import type { TerminalSelectionRange } from "./terminal-selection";
resolveTerminalSelectionRects,
type TerminalSelectionRange,
type TerminalSelectionRect,
} from "./terminal-selection";
const MEASURE_TEXT = "mmmmmmmmmm";
const DEFAULT_FONT_SIZE = 12;
@@ -67,6 +58,11 @@ interface TerminalGridRunProps {
textStyle: StyleProp<TextStyle>;
}
interface TerminalGridSelectionRectProps {
rect: TerminalSelectionRect;
color: string;
}
export interface TerminalGridViewProps {
state: TerminalViewportState;
xtermTheme?: ITheme;
@@ -118,104 +114,9 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
return (
<View style={runStyle}>
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
</Text>
)}
</View>
);
}
interface TerminalGridCustomGlyphRunProps {
run: TerminalCustomGlyphRun;
cellWidth: number;
cellHeight: number;
}
interface TerminalGridCustomGlyphRectProps {
cell: TerminalCustomGlyphCell;
rect: TerminalGlyphRect;
cellWidth: number;
cellHeight: number;
foregroundColor: string;
}
function TerminalGridCustomGlyphRect({
cell,
rect,
cellWidth,
cellHeight,
foregroundColor,
}: TerminalGridCustomGlyphRectProps) {
const style = useMemo<ViewStyle>(
() => ({
position: "absolute",
backgroundColor: foregroundColor,
left: (cell.offset + rect.x) * cellWidth,
top: rect.y * cellHeight,
width: rect.width * cellWidth,
height: rect.height * cellHeight,
}),
[cell.offset, cellHeight, cellWidth, foregroundColor, rect],
);
return <View style={style} />;
}
function TerminalGridCustomGlyphRun({
run,
cellWidth,
cellHeight,
}: TerminalGridCustomGlyphRunProps) {
const opacity = typeof run.style.opacity === "number" ? run.style.opacity : 1;
return (
<View style={[styles.customGlyphRun, { opacity }]}>
{run.glyphs.map((cell) =>
cell.glyph.kind === "rects"
? cell.glyph.rects.map((rect) => (
<TerminalGridCustomGlyphRect
key={`${cell.key}:${rect.x}:${rect.y}:${rect.width}:${rect.height}`}
cell={cell}
rect={rect}
cellWidth={cellWidth}
cellHeight={cellHeight}
foregroundColor={run.foregroundColor}
/>
))
: null,
)}
<Svg
height={cellHeight}
pointerEvents="none"
preserveAspectRatio="none"
style={styles.customGlyphSvg}
viewBox={`0 0 ${run.cellCount * cellWidth} ${cellHeight}`}
width={run.cellCount * cellWidth}
>
<G fill="none" stroke={run.foregroundColor}>
{run.glyphs.map((cell) =>
cell.glyph.kind === "path" ? (
<Path
d={cell.glyph.path}
key={cell.key}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={cell.glyph.strokeWidth}
transform={resolveTerminalCustomGlyphCellTransform({
cellOffset: cell.offset,
cellWidth,
cellHeight,
})}
vectorEffect="non-scaling-stroke"
/>
) : null,
)}
</G>
</Svg>
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
</Text>
</View>
);
}
@@ -229,6 +130,27 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
);
});
function TerminalGridSelectionRect({ rect, color }: TerminalGridSelectionRectProps) {
const rectStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.selectionRect,
{
backgroundColor: color,
height: rect.height,
transform: [{ translateX: rect.x }, { translateY: rect.y }],
width: rect.width,
},
],
[color, rect.height, rect.width, rect.x, rect.y],
);
return <View pointerEvents="none" style={rectStyle} />;
}
const MemoTerminalGridSelectionRect = memo(TerminalGridSelectionRect, (previous, next) => {
return previous.rect === next.rect && previous.color === next.color;
});
function TerminalGridRow({
row,
cellWidth,
@@ -322,21 +244,23 @@ export function TerminalGridView({
[state.grid, visibleCols],
);
const rows = useMemo(
() =>
buildRows({
grid: projectedGrid,
resolver,
selection: selection
? {
range: selection,
firstRow: state.firstRow,
backgroundColor: NATIVE_TERMINAL_SELECTION_COLORS.background,
foregroundColor: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}
: undefined,
}),
[projectedGrid, resolver, selection, state.firstRow],
() => buildRows({ grid: projectedGrid, resolver }),
[projectedGrid, resolver],
);
const selectionRects = useMemo(
() =>
resolveTerminalSelectionRects({
selection,
viewport: {
firstRow: state.firstRow,
rows: state.grid.length,
cols: visibleCols,
},
metrics,
}),
[metrics, selection, state.firstRow, state.grid.length, visibleCols],
);
const selectionColor = xtermTheme.selectionBackground ?? "rgba(90, 160, 255, 0.35)";
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor: resolver.backgroundColor }, style],
@@ -422,6 +346,9 @@ export function TerminalGridView({
styleEpoch={resolver.themeKey}
/>
))}
{selectionRects.map((rect) => (
<MemoTerminalGridSelectionRect key={rect.key} rect={rect} color={selectionColor} />
))}
{!state.cursor.hidden && <View pointerEvents="none" style={cursorStyle} />}
</View>
</View>
@@ -441,15 +368,6 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
customGlyphRun: {
flex: 1,
position: "relative",
},
customGlyphSvg: {
left: 0,
position: "absolute",
top: 0,
},
rowText: {
includeFontPadding: false,
margin: 0,
@@ -466,4 +384,10 @@ const styles = StyleSheet.create({
position: "absolute",
top: 0,
},
selectionRect: {
left: 0,
opacity: 0.8,
position: "absolute",
top: 0,
},
});
@@ -30,7 +30,7 @@ describe("native terminal typed input", () => {
expect(input.receiveTextChange("npm run typecheck")).toEqual({
data: "npm run typecheck",
shouldClear: false,
shouldClear: true,
});
});
@@ -122,11 +122,11 @@ describe("native terminal typed input", () => {
const input = createTerminalTextInputState();
const longText = "x".repeat(512);
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false });
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: true });
input.reset();
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false });
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: true });
});
it("translates software keyboard terminal control keys", () => {
@@ -197,35 +197,14 @@ describe("native terminal typed input", () => {
expect(input.receiveTextChange("echo clipboard item")).toEqual({
data: "echo clipboard item",
shouldClear: false,
shouldClear: true,
});
input.reset();
expect(input.receiveTextChange("echo clipboard item")).toEqual({
data: "echo clipboard item",
shouldClear: false,
});
});
it("does not replay a stale IME suggestion when the native clear leaves composing text behind", () => {
const input = createTerminalTextInputState();
let typed = "";
for (const key of "resta") {
typed += key;
input.receiveKeyPress(key);
input.receiveTextChange(typed);
}
expect(input.receiveTextChange("restaurant ")).toEqual({
data: "urant ",
shouldClear: false,
});
expect(input.receiveTextChange("restaurant x")).toEqual({
data: "x",
shouldClear: false,
shouldClear: true,
});
});
});
@@ -107,7 +107,10 @@ export function createTerminalTextInputState(): TerminalTextInputState {
previousText = text;
return {
data: appendedText,
shouldClear: false,
// Treat multi-character IME commits as complete units. Clearing lets
// the keyboard commit the same clipboard item again without needing
// a different intermediate value.
shouldClear: appendedText.length > 1,
};
},
reset(): void {
@@ -40,81 +40,4 @@ describe("terminal row model", () => {
{ text: "A界", cellCount: 3 },
]);
});
test("redraws selected cells with the terminal selection colors", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const grid = [[cell("a", { fg: 1, fgMode: 1 }), cell("b", { dim: true }), cell("c")]];
const rows = buildRows({
grid,
resolver,
selection: {
range: {
start: { row: 7, col: 1 },
end: { row: 7, col: 2 },
coordinateEpoch: 1,
},
firstRow: 7,
backgroundColor: "#ffff00",
foregroundColor: "#000000",
},
});
expect(
rows[0].runs.map((run) => ({
text: run.text,
color: run.style.color,
backgroundColor: run.style.backgroundColor,
opacity: run.style.opacity,
})),
).toEqual([
{
text: "a",
color: DEFAULT_TERMINAL_THEME.red,
backgroundColor: undefined,
opacity: undefined,
},
{ text: "b", color: "#000000", backgroundColor: "#ffff00", opacity: 1 },
{ text: "c", color: "#000000", backgroundColor: "#ffff00", opacity: 1 },
]);
expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash);
});
test("splits Claude box drawing from font text at exact terminal columns", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const frame = "╭─ Claude Code ───╮";
const [row] = buildRows({
grid: [frame.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))],
resolver,
});
expect(
row.runs.map((run) => ({
text: run.text,
cellCount: run.cellCount,
renderKind: run.renderKind,
})),
).toEqual([
{ text: "╭─", cellCount: 2, renderKind: "custom-glyph" },
{ text: " Claude Code ", cellCount: 13, renderKind: "text" },
{ text: "───╮", cellCount: 4, renderKind: "custom-glyph" },
]);
});
test("keeps Claude mascot glyphs custom while preserving copied row text", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const mascot = "▐▛███▜▌ Claude Code";
const [row] = buildRows({
grid: [mascot.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))],
resolver,
});
expect(row.runs.map((run) => [run.text, run.renderKind])).toEqual([
["▐▛███▜▌", "custom-glyph"],
[" Claude Code", "text"],
]);
expect(row.runs.map((run) => run.text).join("")).toBe(mascot);
});
});
@@ -2,8 +2,6 @@ import type { TextStyle } from "react-native";
import type { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalCellStyleResolver } from "./colors";
import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph";
import type { TerminalSelectionRange } from "./terminal-selection";
const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1100, 0x115f],
@@ -18,30 +16,12 @@ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1f300, 0x1faff],
];
interface TerminalRunBase {
export interface TerminalRun {
key: string;
text: string;
cellCount: number;
styleKey: string;
style: TextStyle;
foregroundColor: string;
}
export interface TerminalTextRun extends TerminalRunBase {
renderKind: "text";
}
export interface TerminalCustomGlyphRun extends TerminalRunBase {
renderKind: "custom-glyph";
glyphs: TerminalCustomGlyphCell[];
}
export type TerminalRun = TerminalCustomGlyphRun | TerminalTextRun;
export interface TerminalCustomGlyphCell {
key: string;
offset: number;
glyph: TerminalCustomGlyph;
}
export interface TerminalRowModel {
@@ -52,13 +32,6 @@ export interface TerminalRowModel {
type TerminalRenderableCell = TerminalCell & { width?: number };
interface TerminalRowSelectionStyle {
range: TerminalSelectionRange;
firstRow: number;
backgroundColor: string;
foregroundColor: string;
}
function hashStringPart(hash: number, value: string): number {
let nextHash = hash;
for (let index = 0; index < value.length; index += 1) {
@@ -102,88 +75,28 @@ function appendRun(input: {
cellCount: number;
styleKey: string;
style: TextStyle;
foregroundColor: string;
customGlyph: TerminalCustomGlyph | null;
col: number;
}): void {
const renderKind = input.customGlyph ? "custom-glyph" : "text";
const previousRun = input.runs[input.runs.length - 1];
if (
previousRun &&
previousRun.styleKey === input.styleKey &&
previousRun.renderKind === renderKind
) {
const offset = previousRun.cellCount;
if (previousRun && previousRun.styleKey === input.styleKey) {
previousRun.text += input.text;
previousRun.cellCount += input.cellCount;
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
previousRun.glyphs.push({
key: `${input.col}:${input.text}`,
offset,
glyph: input.customGlyph,
});
}
return;
}
const baseRun: TerminalRunBase = {
input.runs.push({
key: `${input.col}:${input.styleKey}`,
text: input.text,
cellCount: input.cellCount,
styleKey: input.styleKey,
style: input.style,
foregroundColor: input.foregroundColor,
};
if (input.customGlyph) {
input.runs.push({
...baseRun,
renderKind: "custom-glyph",
glyphs: [{ key: `${input.col}:${input.text}`, offset: 0, glyph: input.customGlyph }],
});
return;
}
input.runs.push({ ...baseRun, renderKind: "text" });
}
function cellIntersectsSelection(input: {
selection: TerminalRowSelectionStyle;
row: number;
col: number;
cellCount: number;
}): boolean {
const absoluteRow = input.selection.firstRow + input.row;
const { start, end } = input.selection.range;
if (absoluteRow < start.row || absoluteRow > end.row) {
return false;
}
const selectedStartCol = absoluteRow === start.row ? start.col : 0;
const selectedEndCol = absoluteRow === end.row ? end.col : Number.POSITIVE_INFINITY;
const cellEndCol = input.col + input.cellCount - 1;
return cellEndCol >= selectedStartCol && input.col <= selectedEndCol;
}
function selectedCellStyle(input: {
style: TextStyle;
foregroundColor: string;
backgroundColor: string;
}): TextStyle {
return {
...input.style,
backgroundColor: input.backgroundColor,
color: input.foregroundColor,
opacity: 1,
...(input.style.textDecorationLine
? { textDecorationColor: input.foregroundColor }
: undefined),
};
});
}
function buildRowModel(input: {
cells: TerminalRenderableCell[];
index: number;
resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel {
const runs: TerminalRun[] = [];
let hash = 2166136261;
@@ -193,42 +106,17 @@ function buildRowModel(input: {
const text = cell.char || " ";
const resolvedStyle = input.resolver.resolve(cell);
const cellCount = terminalCellCount(input.cells, col, text);
const customGlyph = resolveTerminalCustomGlyph(text);
const selection = input.selection;
let styleKey = resolvedStyle.key;
let style = resolvedStyle.style;
let foregroundColor = resolvedStyle.foregroundColor;
if (
selection &&
cellIntersectsSelection({
selection,
row: input.index,
col,
cellCount,
})
) {
styleKey = `${resolvedStyle.key}|selection:${selection.foregroundColor}:${selection.backgroundColor}`;
style = selectedCellStyle({
style: resolvedStyle.style,
foregroundColor: selection.foregroundColor,
backgroundColor: selection.backgroundColor,
});
foregroundColor = selection.foregroundColor;
}
appendRun({
runs,
text,
cellCount,
styleKey,
style,
foregroundColor,
customGlyph,
styleKey: resolvedStyle.key,
style: resolvedStyle.style,
col,
});
hash = hashStringPart(hash, text);
hash = hashStringPart(hash, String(cellCount));
hash = hashStringPart(hash, styleKey);
hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text");
hash = hashStringPart(hash, resolvedStyle.key);
if (cellCount > 1) {
col += 1;
@@ -245,9 +133,8 @@ function buildRowModel(input: {
export function buildRows(input: {
grid: TerminalRenderableCell[][];
resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel[] {
return input.grid.map((cells, index) =>
buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }),
buildRowModel({ cells, index, resolver: input.resolver }),
);
}
@@ -276,26 +276,6 @@ describe("native terminal selection", () => {
expect(copied).toEqual(["COPY_OK_123"]);
});
it("copies custom-drawn Claude glyphs as their original Unicode text", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
const art = "╭─ Claude ─╮\r\n│ ▐▛███▜▌ │\r\n╰──────────╯";
await terminal.write(art);
const bounds = terminal.getBufferBounds();
const firstRow = rowWithText(terminal, "╭─ Claude ─╮");
const lastRow = rowWithText(terminal, "╰──────────╯");
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: firstRow, col: 0 },
end: { row: lastRow, col: 11 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe(art.replaceAll("\r", ""));
});
it("copies known text after it has scrolled into retained history", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 });
await terminal.write("KEEP_THIS_LINE\r\n");
@@ -2715,49 +2715,6 @@ describe("processTimelineResponse", () => {
expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" }));
});
it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
makeAssistantTimelineEvent("First paragraph.\n\nSecond", "assistant-1"),
80,
),
makeStreamReducerEvent(makeAssistantTimelineEvent(" paragraph.", "assistant-1"), 81),
makeStreamReducerEvent(makeTimelineEvent("newest prompt", "user_message"), 82),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
});
expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([
"First paragraph.",
"Second paragraph.",
]);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
currentCursor: live.cursor ?? undefined,
payload: {
...baseTimelineInput.payload,
direction: "before",
mergeWindow: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 40 },
hasOlder: false,
hasNewer: true,
entries: [makeTimelineEntry(1, "oldest prompt", "user_message")],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
"First paragraph.",
"Second paragraph.",
]);
});
it("fills the gap between a retained prompt window and the contiguous tail", () => {
const currentTail: StreamItem[] = [
{
@@ -190,6 +190,39 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("an idle reconciliation waits for an in-flight tail and fetches a fresh snapshot", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initialTail = await world.nextFetch("agent-a");
world.sync.reconcileAgent("agent-a");
world.expectNoPendingFetch();
initialTail.respond({ hasNewer: false });
const reconciliation = await world.nextFetch("agent-a");
expect(reconciliation.request).toEqual({
direction: "tail",
limit: 40,
projection: "projected",
});
reconciliation.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("does not reconcile an agent outside the viewed membership", () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.reconcileAgent("agent-a");
world.expectNoPendingFetch();
world.expectNoPendingMembership();
});
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -33,6 +33,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
setActive(active: boolean): void;
setConnected(connected: boolean): void;
setDeliveryMode(mode: TimelineDeliveryMode): void;
reconcileAgent(agentId: string): void;
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
dispose(): void;
}
@@ -450,6 +451,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (deliveryMode === "selective" && connected) void reconcileMembership();
else if (connected) startAcknowledgedCatchUps();
},
reconcileAgent(agentId) {
if (!isDesired(agentId)) return;
const request = planTimelineTailFetch();
if (catchUps.get(agentId)?.status === "running") {
pendingCatchUps.set(agentId, request);
return;
}
startCatchUp(agentId, { request, supersede: true });
},
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
startCatchUp(agentId, {
-24
View File
@@ -616,30 +616,6 @@ describe("stream reducer canonical tool calls", () => {
expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length);
});
it("keeps the timeline position on every promoted assistant block", () => {
const timelineCursor = { epoch: "epoch-1", seq: 42 };
const result = applyStreamEvent({
tail: [],
head: [],
event: assistantTimeline("First paragraph.\n\nSecond paragraph.", undefined, "message-1"),
timestamp: new Date("2025-01-01T10:02:00Z"),
timelineCursor,
});
const messages = [...result.tail, ...result.head].filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
expect(messages.map((message) => message.text)).toEqual([
"First paragraph.",
"Second paragraph.",
]);
expect(messages.map((message) => message.timelineCursor)).toEqual([
timelineCursor,
timelineCursor,
]);
});
it("preserves old assistant merge behavior when message ids are absent", () => {
const state = hydrateStreamState([
{
+3 -1
View File
@@ -1573,14 +1573,16 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
const completedBlocks = blocks.slice(0, -1);
const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`;
const promotedItems = completedBlocks.map<AssistantMessageItem>((block, offset) => ({
...activeItem,
kind: "assistant_message",
id: createAssistantBlockId({
groupId: blockGroupId,
blockIndex: firstBlockIndex + offset,
}),
...(activeItem.messageId ? { messageId: activeItem.messageId } : {}),
blockGroupId,
blockIndex: firstBlockIndex + offset,
text: block,
timestamp: activeItem.timestamp,
}));
const nextTail = flushHeadToTail(params.tail, promotedItems);
@@ -134,27 +134,6 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
});
it("passes performance tracing into the connected client", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const trace = {
isEnabled: () => true,
beginSection: vi.fn(),
endSection: vi.fn(),
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
{ trace },
probe.deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.trace).toBe(trace);
});
it("uses relay TLS from the stored connection", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
@@ -97,10 +97,7 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
options?: {
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
},
options?: { capabilities?: DaemonClientConfig["capabilities"] },
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
@@ -115,7 +112,6 @@ export async function buildClientConfig(
suppressSendErrors: true,
reconnect: { enabled: false },
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
localTransportFactory
? { transportFactory: localTransportFactory }
@@ -228,7 +224,6 @@ interface ProbeOptions {
serverId?: string;
timeoutMs?: number;
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
}
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
test: {
name: "unit",
environment: "node",
include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.0-beta.1",
"@getpaseo/protocol": "0.3.0-beta.1",
"@getpaseo/server": "0.3.0-beta.1",
"@getpaseo/client": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/server": "0.2.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.3.0-beta.1",
"version": "0.2.5",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.3.0-beta.1",
"@getpaseo/relay": "0.3.0-beta.1",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -1,6 +1,5 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import type {
GetProvidersSnapshotResponseMessage,
ListProviderModelsResponseMessage,
@@ -60,9 +59,7 @@ export function normalizeListProviderModelsPayload(
export function normalizeProvidersSnapshotPayload<
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
>(payload: T): T {
const entries = payload.compactSnapshot
? expandProviderSnapshot(payload.compactSnapshot)
: normalizeProviderSnapshotEntries(payload.entries);
const entries = normalizeProviderSnapshotEntries(payload.entries);
return entries === payload.entries ? payload : { ...payload, entries };
}
+1 -150
View File
@@ -1,11 +1,6 @@
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import {
DaemonClient,
type DaemonClientTrace,
type DaemonTransport,
type Logger,
} from "./daemon-client";
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
@@ -39,24 +34,6 @@ function createMockLogger() {
};
}
interface TraceRecord {
phase: "begin" | "end";
name?: string;
args?: Record<string, string>;
}
function createTraceRecorder(): { trace: DaemonClientTrace; records: TraceRecord[] } {
const records: TraceRecord[] = [];
return {
trace: {
isEnabled: () => true,
beginSection: (name, args) => records.push({ phase: "begin", name, args }),
endSection: () => records.push({ phase: "end" }),
},
records,
};
}
function createMockTransport() {
const sent: Array<string | Uint8Array | ArrayBuffer> = [];
@@ -184,56 +161,6 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
test("traces WebSocket frames, message types, and JSON parse duration", async () => {
const mock = createMockTransport();
const recorder = createTraceRecorder();
const client = new DaemonClient({
url: "ws://test",
clientId: "trace_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
trace: recorder.trace,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
expect(recorder.records).toEqual([
{
phase: "begin",
name: "paseo.ws.message.outbound",
args: { envelopeType: "hello", messageType: "hello" },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.outbound",
args: { kind: "text", size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.inbound",
args: { kind: "text", size: expect.any(String) },
},
{
phase: "begin",
name: "paseo.ws.json.parse",
args: { size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.message.inbound",
args: { envelopeType: "session", messageType: "status" },
},
{ phase: "end" },
{ phase: "end" },
]);
});
test("does not infer browser automation capabilities from Electron runtime", async () => {
vi.stubGlobal("navigator", {
userAgent:
@@ -737,7 +664,6 @@ test("advertises client capabilities in hello", async () => {
clientType: "cli",
protocolVersion: 1,
capabilities: {
compact_provider_snapshots: true,
custom_mode_icons: true,
project_updates: true,
provider_subagents: true,
@@ -4458,81 +4384,6 @@ test("lists available providers via RPC", async () => {
});
});
test("requests provider snapshots conditionally and expands the compact response", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_provider_snapshot_test",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getProvidersSnapshot({ cwd: "/repo", ifNoneMatch: "previous-hash" });
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "get_providers_snapshot_request",
cwd: "/repo",
ifNoneMatch: "previous-hash",
});
mock.triggerMessage(
wrapSessionMessage({
type: "get_providers_snapshot_response",
payload: {
entries: [],
compactSnapshot: {
entries: [
{
provider: "pi",
status: "ready",
enabled: true,
models: [
{ id: "model-a", label: "Model A", thinkingSet: 0 },
{ id: "model-b", label: "Model B", thinkingSet: 0 },
],
},
],
thinkingSets: [
{
options: [{ id: "high", label: "High", isDefault: true }],
defaultOptionId: "high",
},
],
},
snapshotHash: "next-hash",
generatedAt: "2026-08-04T00:00:00.000Z",
requestId: request.requestId,
},
}),
);
const response = await promise;
expect(response.entries[0]?.models).toEqual([
{
provider: "pi",
id: "model-a",
label: "Model A",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
defaultThinkingOptionId: "high",
},
{
provider: "pi",
id: "model-b",
label: "Model B",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
defaultThinkingOptionId: "high",
},
]);
expect(response.entries[0]?.models?.[0]?.thinkingOptions).toBe(
response.entries[0]?.models?.[1]?.thinkingOptions,
);
});
test("lists commands with draft config via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+30 -136
View File
@@ -317,16 +317,9 @@ export interface DaemonClientConfig {
};
runtimeMetricsIntervalMs?: number;
runtimeMetricsWindowMs?: number;
trace?: DaemonClientTrace;
capabilities?: Partial<Record<ClientCapability, unknown>>;
}
export interface DaemonClientTrace {
isEnabled(): boolean;
beginSection(name: string, args?: Record<string, string>): void;
endSection(): void;
}
export interface SendMessageOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
@@ -1042,26 +1035,6 @@ function concatByteChunks(chunks: Uint8Array[], size: number): Uint8Array {
return bytes;
}
function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number {
if (typeof frame === "string") {
return frame.length;
}
return frame.byteLength;
}
function describeInboundTransportFrame(
frame: unknown,
rawBytes: Uint8Array | null,
): Record<string, string> {
if (typeof frame === "string") {
return { kind: "text", size: String(frame.length) };
}
if (rawBytes) {
return { kind: "binary", size: String(rawBytes.byteLength) };
}
return { kind: "unknown", size: "0" };
}
function hashForLog(value: string): string {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -1562,49 +1535,6 @@ export class DaemonClient {
// Core Send Helpers
// ============================================================================
private beginTraceSection(name: string, args?: Record<string, string>): boolean {
const trace = this.config.trace;
if (!trace?.isEnabled()) {
return false;
}
trace.beginSection(name, args);
return true;
}
private endTraceSection(isOpen: boolean): void {
if (isOpen) {
this.config.trace?.endSection();
}
}
private traceInstant(name: string, args?: Record<string, string>): void {
const isOpen = this.beginTraceSection(name, args);
this.endTraceSection(isOpen);
}
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
this.traceInstant("paseo.ws.message.outbound", {
envelopeType,
messageType,
});
this.sendTransportFrame(JSON.stringify(message));
}
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
if (!this.transport) {
throw new Error("Transport not connected");
}
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
kind: typeof frame === "string" ? "text" : "binary",
size: String(getTransportFrameSize(frame)),
});
try {
this.transport.send(frame);
} finally {
this.endTraceSection(isOpen);
}
}
/**
* Send a session message. For fire-and-forget messages (heartbeats, etc.),
* failures are suppressed if `suppressSendErrors` is configured.
@@ -1619,7 +1549,7 @@ export class DaemonClient {
}
const payload = SessionInboundMessageSchema.parse(message);
try {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.transport.send(JSON.stringify({ type: "session", message: payload }));
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -1636,11 +1566,7 @@ export class DaemonClient {
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
try {
this.traceInstant("paseo.ws.message.outbound", {
envelopeType: "binary",
messageType: "binary",
});
this.sendTransportFrame(frame);
this.transport.send(frame);
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -1661,7 +1587,7 @@ export class DaemonClient {
// If connected, send immediately
if (this.transport && status === "connected") {
const payload = SessionInboundMessageSchema.parse(message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.transport.send(JSON.stringify({ type: "session", message: payload }));
return Promise.resolve();
}
@@ -1697,7 +1623,7 @@ export class DaemonClient {
try {
if (this.transport && this.connectionState.status === "connected") {
const payload = SessionInboundMessageSchema.parse(pending.message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.transport.send(JSON.stringify({ type: "session", message: payload }));
pending.resolve();
} else {
pending.reject(new Error("Connection lost before message could be sent"));
@@ -1851,7 +1777,7 @@ export class DaemonClient {
}
const payload = SessionInboundMessageSchema.parse(message);
try {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.transport.send(JSON.stringify({ type: "session", message: payload }));
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
@@ -2026,7 +1952,7 @@ export class DaemonClient {
this.pingProbe = probe;
try {
this.sendJsonMessage("ping", "ping", { type: "ping" });
this.transport.send(JSON.stringify({ type: "ping" }));
} catch (error) {
this.clearPingProbe();
const sendError = error instanceof Error ? error : new Error(String(error));
@@ -4495,7 +4421,6 @@ export class DaemonClient {
async getProvidersSnapshot(options?: {
cwd?: string;
ifNoneMatch?: string;
requestId?: string;
}): Promise<GetProvidersSnapshotPayload> {
const payload = await this.sendCorrelatedSessionRequest({
@@ -4503,7 +4428,6 @@ export class DaemonClient {
message: {
type: "get_providers_snapshot_request",
cwd: options?.cwd,
ifNoneMatch: options?.ifNoneMatch,
},
responseType: "get_providers_snapshot_response",
});
@@ -5402,22 +5326,23 @@ export class DaemonClient {
}
try {
this.sendJsonMessage("hello", "hello", {
type: "hello",
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,
[CLIENT_CAPS.projectUpdates]: true,
[CLIENT_CAPS.compactProviderSnapshots]: true,
...this.config.capabilities,
},
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
});
this.transport.send(
JSON.stringify({
type: "hello",
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,
[CLIENT_CAPS.projectUpdates]: true,
...this.config.capabilities,
},
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to send hello message";
this.lastErrorValue = message;
@@ -5488,37 +5413,24 @@ export class DaemonClient {
}
const rawBytes = asUint8Array(rawData);
const isOpen = this.beginTraceSection(
"paseo.ws.frame.inbound",
describeInboundTransportFrame(rawData, rawBytes),
);
try {
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
return;
}
const payload = decodeMessageData(rawData);
if (!payload) {
return;
}
this.handleJsonPayload(payload, rawBytes?.byteLength);
} finally {
this.endTraceSection(isOpen);
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
return;
}
const payload = decodeMessageData(rawData);
if (!payload) {
return;
}
this.handleJsonPayload(payload, rawBytes?.byteLength);
}
private handleJsonPayload(payload: string, rawBytesLength: number | undefined): void {
const bytes = rawBytesLength ?? payload.length;
const startMs = perfNow();
let parsedJson: unknown;
const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
size: String(bytes),
});
try {
parsedJson = JSON.parse(payload);
} catch {
return;
} finally {
this.endTraceSection(parseTraceOpen);
}
const parsed = validateWSOutboundMessage(parsedJson);
@@ -5545,19 +5457,11 @@ export class DaemonClient {
this.consecutiveLivenessFailures = 0;
if (parsed.data.type === "pong") {
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "pong",
messageType: "pong",
});
this.resolvePingProbe();
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
return;
}
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "session",
messageType: parsed.data.message.type,
});
this.handleSessionMessage(parsed.data.message);
const msgType = parsed.data.message.type;
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
@@ -5569,11 +5473,6 @@ export class DaemonClient {
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "binary",
messageType: "file",
opcode: String(fileFrame.opcode),
});
this.consecutiveLivenessFailures = 0;
this.handleFileTransferFrame(fileFrame);
this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
@@ -5584,11 +5483,6 @@ export class DaemonClient {
if (!frame) {
return false;
}
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "binary",
messageType: "terminal",
opcode: String(frame.opcode),
});
this.consecutiveLivenessFailures = 0;
const binaryStartMs = perfNow();
this.terminalStreams.handleFrame(frame);
-6
View File
@@ -17,12 +17,6 @@ files:
- "!node_modules/@getpaseo/**/*.test.*"
- "!node_modules/@getpaseo/**/*.spec.*"
- "!node_modules/@getpaseo/server/dist/server/web-ui/**"
# Workspace packages are symlinked, so anything a dev generates inside them
# lands in the asar. CI never has these; a local .debug run adds gigabytes.
- "!node_modules/@getpaseo/*/.debug/**"
- "!node_modules/@getpaseo/*/.tasks/**"
- "!node_modules/@getpaseo/*/conversations/**"
- "!node_modules/@getpaseo/*/test-results/**"
asarUnpack:
- dist/daemon/node-entrypoint-runner.js
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*

Some files were not shown because too many files have changed in this diff Show More