mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d227ff3e83 | ||
|
|
7808a2a7d8 | ||
|
|
669253bdf6 | ||
|
|
0b624b5585 | ||
|
|
dfd18a44a7 | ||
|
|
d7f72d2805 | ||
|
|
9916e3d185 | ||
|
|
34c538602a | ||
|
|
62f8b14804 | ||
|
|
8ac7cd476c | ||
|
|
d191fa6685 | ||
|
|
adf508a1c8 | ||
|
|
dac255cb99 | ||
|
|
7dbeff8290 | ||
|
|
8e5b6876b4 | ||
|
|
9194449952 | ||
|
|
1e392557f2 | ||
|
|
b020a1d1ed | ||
|
|
7b6965856b | ||
|
|
238047eaa8 | ||
|
|
683f38443c | ||
|
|
bfec5a05b9 | ||
|
|
8b9314b70a | ||
|
|
22cb7f9996 | ||
|
|
bf6cd09d6c | ||
|
|
56cb70fa59 | ||
|
|
dc6f13e31e | ||
|
|
32705cb3f2 | ||
|
|
e90832eedd | ||
|
|
df32e6ddcd | ||
|
|
e238fea158 | ||
|
|
98c9878c57 |
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0-beta.2 - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal output keeps updating when you leave the app and come back ([#2896](https://github.com/getpaseo/paseo/pull/2896))
|
||||
|
||||
## 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
|
||||
|
||||
@@ -67,6 +67,17 @@ 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
|
||||
|
||||
@@ -96,6 +96,21 @@ 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:
|
||||
@@ -134,5 +149,6 @@ 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.
|
||||
|
||||
+37
-10
@@ -26,6 +26,17 @@ 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`
|
||||
@@ -86,16 +97,32 @@ and a column of icons there is decoration competing with the values you actually
|
||||
|
||||
## Surface details
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -70,6 +70,8 @@ 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.
|
||||
|
||||
+6
-4
@@ -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 `release-mobile.yml` in this repo.
|
||||
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`.
|
||||
|
||||
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, but they do not trigger the production web/mobile release flows
|
||||
- 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.
|
||||
- `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,9 +238,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
|
||||
sha256-uCYkY9w6UQamIwez+2JwbG4zPp5X+G5sb7MfZDj2KgI=
|
||||
|
||||
Generated
+36
-36
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -29874,20 +29874,6 @@
|
||||
"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",
|
||||
@@ -35323,7 +35309,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -35394,7 +35380,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.19.2",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
@@ -36259,6 +36245,20 @@
|
||||
"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.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.5",
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/server": "0.2.5",
|
||||
"@getpaseo/client": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/server": "0.3.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36615,10 +36615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/relay": "0.2.5",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/relay": "0.3.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36629,7 +36629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"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.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37768,7 +37768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38000,7 +38000,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38013,7 +38013,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38231,15 +38231,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.5",
|
||||
"@getpaseo/highlight": "0.2.5",
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/relay": "0.2.5",
|
||||
"@getpaseo/client": "0.3.0-beta.2",
|
||||
"@getpaseo/highlight": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/relay": "0.3.0-beta.2",
|
||||
"@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.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -1,9 +1,12 @@
|
||||
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
|
||||
? {
|
||||
@@ -45,30 +48,6 @@ 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) {
|
||||
@@ -111,13 +90,13 @@ const variants = {
|
||||
};
|
||||
|
||||
const variant = variants[appVariant] ?? variants.production;
|
||||
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
|
||||
const nativeReleaseVersion = getNativeReleaseVersion(pkg.version);
|
||||
|
||||
export default {
|
||||
expo: {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: pkg.version,
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
@@ -133,7 +112,7 @@ export default {
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
buildNumber: String(nativeBuildVersionCode),
|
||||
buildNumber: nativeReleaseVersion.iosBuildNumber,
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -147,7 +126,7 @@ export default {
|
||||
usesCleartextTraffic: true,
|
||||
permissions: buildProfile.androidPermissions,
|
||||
package: variant.packageId,
|
||||
versionCode: nativeBuildVersionCode,
|
||||
versionCode: nativeReleaseVersion.androidVersionCode,
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
@@ -193,6 +172,7 @@ export default {
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
...(isProfileBuild ? [withAndroidProfileable] : []),
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -201,6 +181,7 @@ export default {
|
||||
},
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
profileBuild: isProfileBuild,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
@@ -915,39 +915,6 @@ 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,6 +4,7 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
expectAttachmentSheetRowsOnTitleRail,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
@@ -63,6 +64,22 @@ 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,13 +108,12 @@ test.describe("Host appearance", () => {
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
const badge = row.getByTestId(`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,6 +4,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openMissingProjectNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
clickArchiveWorkspaceMenuItem,
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
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
|
||||
@@ -66,6 +70,11 @@ 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 });
|
||||
@@ -77,6 +86,10 @@ 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,6 +51,7 @@ 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";
|
||||
@@ -78,9 +79,7 @@ interface WorkspaceStatusGroupEvent {
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
|
||||
@@ -132,9 +133,7 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Switch to status grouping.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -39,9 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
|
||||
}
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -227,18 +228,19 @@ 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(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
|
||||
await expect(row).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText } from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
@@ -97,6 +98,15 @@ async function captureTerminalText(
|
||||
return capture.lines.join("\n");
|
||||
}
|
||||
|
||||
async function waitForTerminalMarker(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => getTerminalBufferText(page), {
|
||||
message: "terminal stream should deliver PTY output after focus returns",
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toContain(marker);
|
||||
}
|
||||
|
||||
test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
@@ -174,4 +184,22 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
)
|
||||
.toEqual({ rows: rendered.rows, cols: rendered.cols });
|
||||
});
|
||||
|
||||
test("terminal output remains subscribed after window focus returns", async ({ page }) => {
|
||||
await withTerminalInApp(page, harness, { name: "focus-return-output" }, async (terminal) => {
|
||||
const marker = `OUTPUT_AFTER_FOCUS_${Date.now()}`;
|
||||
|
||||
await setWindowFocused(page, false);
|
||||
await page.waitForTimeout(50);
|
||||
await setWindowFocused(page, true);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
harness.client.sendTerminalInput(terminal.id, {
|
||||
type: "input",
|
||||
data: `printf "\\n${marker}\\n"\n`,
|
||||
});
|
||||
|
||||
await waitForTerminalMarker(page, marker);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -82,9 +83,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
|
||||
@@ -397,9 +398,7 @@ async function expectWorkspaceRowInStatusBucket(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
|
||||
|
||||
@@ -102,6 +102,8 @@ 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 },
|
||||
@@ -111,31 +113,41 @@ export async function scrollChatAwayFromBottom(
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// 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);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
await wheelSettled;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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();
|
||||
@@ -71,7 +72,28 @@ 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")).toBeVisible({ timeout: 5_000 });
|
||||
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);
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
|
||||
@@ -123,11 +123,17 @@ export async function waitForConnectedHost(
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSidebarHostFilter(page: Page): Promise<void> {
|
||||
export async function openSidebarDisplayPreferences(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();
|
||||
}
|
||||
|
||||
@@ -189,7 +195,7 @@ export async function chooseHostBadgeDisplay(
|
||||
function hostBadge(page: Page, input: HostBadgeTarget) {
|
||||
return page
|
||||
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`)
|
||||
.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
.getByTestId(`host-badge-${input.serverId}`);
|
||||
}
|
||||
|
||||
interface HostBadgeTarget {
|
||||
@@ -217,12 +223,16 @@ 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));
|
||||
}
|
||||
|
||||
@@ -232,7 +242,7 @@ export async function expectHostAppearancePreview(
|
||||
): Promise<void> {
|
||||
const preview = page.getByTestId("host-appearance-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
const badge = preview.getByTestId(`host-badge-${input.serverId}`);
|
||||
await expect(badge).toHaveText(input.hostName);
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||
}
|
||||
|
||||
@@ -235,6 +235,33 @@ 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,7 +39,6 @@ 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,6 +166,19 @@ 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["sh.paseo.trace.PaseoNativeTraceModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "paseo-native-trace",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"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.19.2",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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,12 +111,14 @@ 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";
|
||||
@@ -145,8 +147,9 @@ import {
|
||||
} from "@/constants/platform";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { useHostBadges } from "@/hosts/use-host-badges";
|
||||
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
@@ -663,6 +666,7 @@ function WorkspaceRowRightGroup({
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -674,9 +678,10 @@ function WorkspaceRowRightGroup({
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -973,9 +978,10 @@ function ProjectHeaderRow({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
@@ -983,7 +989,7 @@ function ProjectHeaderRow({
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1001,6 +1007,7 @@ function ProjectHeaderRow({
|
||||
enabledOnMobile={false}
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
@@ -1119,6 +1126,7 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
@@ -1888,23 +1896,14 @@ 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 = useMemo(
|
||||
() =>
|
||||
selectHostBadges({
|
||||
hosts,
|
||||
localServerId,
|
||||
localHostResolutionPending: localDaemon.status !== "resolved",
|
||||
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
|
||||
}),
|
||||
[hosts, localDaemon.status, localServerId, projects, rowItems.host],
|
||||
);
|
||||
const hostBadgeByServerId = useHostBadges({
|
||||
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
|
||||
});
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 {
|
||||
@@ -729,6 +730,7 @@ function StatusWorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -812,14 +814,16 @@ 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={showKebab} scrim={showScrim}>
|
||||
{showKebab && onArchive ? (
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{kebab.showKebab && onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
|
||||
@@ -71,9 +71,18 @@ 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"> {
|
||||
interface SidebarWorkspaceMenuItemsProps extends Omit<
|
||||
SidebarWorkspaceMenuProps,
|
||||
"onArchive" | "open" | "onOpenChange"
|
||||
> {
|
||||
onArchive?: () => void;
|
||||
}
|
||||
|
||||
@@ -202,10 +211,12 @@ export function SidebarWorkspaceMenu({
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenu compactMode="sheet" open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
@@ -239,7 +250,7 @@ export function SidebarWorkspaceMenu({
|
||||
|
||||
type ContextTriggerProps = Omit<
|
||||
ComponentProps<typeof ContextMenuTrigger>,
|
||||
"children" | "enabledOnMobile"
|
||||
"children" | "enabledOnMobile" | "highlightStyle"
|
||||
>;
|
||||
|
||||
export function SidebarWorkspaceContextMenu({
|
||||
@@ -264,6 +275,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
accessibilityLabel,
|
||||
highlightStyle,
|
||||
...triggerProps
|
||||
}: PropsWithChildren<
|
||||
SidebarWorkspaceMenuItemsProps &
|
||||
@@ -274,6 +286,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
leadingProjectName?: string | null;
|
||||
hostBadgeLabel?: string | null;
|
||||
scriptSummary?: WorkspaceScriptSummary | null;
|
||||
highlightStyle: ComponentProps<typeof ContextMenuTrigger>["highlightStyle"];
|
||||
}
|
||||
>) {
|
||||
const {
|
||||
@@ -301,6 +314,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
{...triggerProps}
|
||||
enabledOnMobile={false}
|
||||
accessibilityLabel={accessibilityLabel ?? rowAccessibilityLabel}
|
||||
highlightStyle={highlightStyle}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -32,6 +32,7 @@ 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 {
|
||||
@@ -324,6 +325,7 @@ 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}
|
||||
@@ -419,6 +421,7 @@ function WorkspaceRowTrailingActions({
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -430,9 +433,10 @@ function WorkspaceRowTrailingActions({
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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,11 +8,10 @@ import {
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
Globe,
|
||||
Server,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
|
||||
import { identityColor } from "@/styles/identity-colors";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -27,11 +26,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.
|
||||
* 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.
|
||||
*/
|
||||
const META_ICON_SIZE = 12;
|
||||
const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
@@ -39,21 +38,10 @@ 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
|
||||
@@ -103,7 +91,7 @@ function MetaItemNode({
|
||||
hostBadge: HostBadgeModel | null;
|
||||
}): ReactNode {
|
||||
if (item.kind === "host") {
|
||||
return hostBadge ? <HostItem hostBadge={hostBadge} /> : null;
|
||||
return hostBadge ? <HostBadge badge={hostBadge} /> : null;
|
||||
}
|
||||
if (item.kind === "changeRequest") {
|
||||
return <PullRequestItem hint={item.hint} />;
|
||||
@@ -114,23 +102,6 @@ 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,
|
||||
@@ -280,15 +251,6 @@ 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,
|
||||
},
|
||||
@@ -298,13 +260,6 @@ 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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
|
||||
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
DEFAULT_TERMINAL_INPUT_MODE_STATE,
|
||||
type TerminalInputModeState,
|
||||
@@ -53,6 +54,10 @@ 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 {
|
||||
@@ -85,6 +90,7 @@ interface TerminalPaneProps {
|
||||
}
|
||||
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -479,6 +485,66 @@ export function TerminalPane({
|
||||
setStreamError(status.error);
|
||||
}, []);
|
||||
|
||||
const getPreferredStreamSize = useStableEvent(() => {
|
||||
if (
|
||||
!canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalActiveRef.current,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return measuredTerminalSizeRef.current;
|
||||
});
|
||||
|
||||
const handleStreamOutput = useStableEvent(
|
||||
({ terminalId: outputTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.writeOutput(data);
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamRestore = useStableEvent(
|
||||
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.restoreOutput(data);
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamSnapshot = useStableEvent(
|
||||
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
},
|
||||
);
|
||||
|
||||
const getStreamRestoreOptions = useStableEvent(() =>
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes,
|
||||
canClaimSize: canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalActiveRef.current,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
}),
|
||||
size: measuredTerminalSizeRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamControllerRef.current?.dispose();
|
||||
streamControllerRef.current = null;
|
||||
@@ -491,55 +557,11 @@ export function TerminalPane({
|
||||
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => {
|
||||
if (
|
||||
!canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalActiveRef.current,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return measuredTerminalSizeRef.current;
|
||||
},
|
||||
onOutput: ({ terminalId: outputTerminalId, data }) => {
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.writeOutput(data);
|
||||
},
|
||||
onRestore: ({ terminalId: restoreTerminalId, data }) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.restoreOutput(data);
|
||||
},
|
||||
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
|
||||
if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
},
|
||||
getRestoreOptions: () => {
|
||||
return resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes,
|
||||
canClaimSize: canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalActiveRef.current,
|
||||
isPaneFocused,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
}),
|
||||
size: measuredTerminalSizeRef.current,
|
||||
});
|
||||
},
|
||||
getPreferredSize: getPreferredStreamSize,
|
||||
onOutput: handleStreamOutput,
|
||||
onRestore: handleStreamRestore,
|
||||
onSnapshot: handleStreamSnapshot,
|
||||
getRestoreOptions: getStreamRestoreOptions,
|
||||
onStatusChange: handleStreamControllerStatus,
|
||||
});
|
||||
|
||||
@@ -553,14 +575,13 @@ export function TerminalPane({
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
getPreferredStreamSize,
|
||||
getStreamRestoreOptions,
|
||||
handleStreamControllerStatus,
|
||||
handleStreamOutput,
|
||||
handleStreamRestore,
|
||||
handleStreamSnapshot,
|
||||
isConnected,
|
||||
isAppActivelyVisible,
|
||||
isPaneFocused,
|
||||
rendererReadyStreamKey,
|
||||
supportsTerminalRestoreModes,
|
||||
terminalStreamKey,
|
||||
workspaceTerminalSession.snapshots,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -759,52 +780,70 @@ export function TerminalPane({
|
||||
],
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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,7 +7,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
@@ -28,6 +27,7 @@ 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,6 +99,7 @@ type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => Sty
|
||||
export function ContextMenuTrigger({
|
||||
children,
|
||||
disabled,
|
||||
highlightStyle,
|
||||
style,
|
||||
enabled = true,
|
||||
enabledOnMobile = true,
|
||||
@@ -108,6 +109,7 @@ export function ContextMenuTrigger({
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
style?: TriggerStyleProp;
|
||||
enabled?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
@@ -184,7 +186,7 @@ export function ContextMenuTrigger({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
{...props}
|
||||
ref={handleRef}
|
||||
collapsable={false}
|
||||
@@ -194,8 +196,9 @@ export function ContextMenuTrigger({
|
||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||
onContextMenu={handleContextMenu}
|
||||
style={pressableStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @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,12 +24,14 @@ 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;
|
||||
@@ -104,7 +106,6 @@ 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();
|
||||
@@ -126,21 +127,6 @@ 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) {
|
||||
@@ -148,14 +134,19 @@ export function useMenuState({
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
if (!onSelect) return;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
pendingSelectRef.current = onSelect ?? null;
|
||||
setOpen(false);
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
onSelect?.();
|
||||
onSelect();
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
@@ -177,7 +168,6 @@ export function useMenuState({
|
||||
open: isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
triggerRef,
|
||||
anchorRect,
|
||||
setAnchorRect,
|
||||
@@ -187,17 +177,6 @@ export function useMenuState({
|
||||
closeSub,
|
||||
goBack,
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
anchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
],
|
||||
[isOpen, setOpen, selectItem, 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 { useMenuContext } from "./menu-context";
|
||||
import { MenuDepthProvider, useMenuContext } from "./menu-context";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedCheckCircle = withUnistyles(CheckCircle);
|
||||
@@ -28,20 +28,55 @@ 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 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.
|
||||
* Height of the filled part of a row, which is also its hit target.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const MENU_ITEM_HEIGHT = 28;
|
||||
const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
|
||||
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,
|
||||
@@ -247,6 +282,10 @@ 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],
|
||||
@@ -260,10 +299,15 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
separator: {
|
||||
height: 1,
|
||||
marginVertical: theme.spacing[1],
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
},
|
||||
hintContainer: {
|
||||
@@ -278,21 +322,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// 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 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 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.
|
||||
// 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.
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: MENU_ITEM_HEIGHT,
|
||||
gap: theme.spacing[2],
|
||||
margin: theme.spacing[1],
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
|
||||
@@ -384,12 +384,10 @@ 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");
|
||||
@@ -438,7 +436,6 @@ export function MenuOverlay({
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onDismiss={onDismiss}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{overlay}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getMenuSheetBottomPadding(input: {
|
||||
safeAreaBottom: number;
|
||||
breathingRoom: number;
|
||||
}): number {
|
||||
return input.safeAreaBottom + input.breathingRoom;
|
||||
}
|
||||
@@ -14,15 +14,18 @@ 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 type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext, MenuContextProvider, MenuDepthProvider } from "./menu-context";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { useMenuContext, MenuContextProvider } from "./menu-context";
|
||||
import { MenuPage } from "./menu-item";
|
||||
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);
|
||||
@@ -205,7 +208,7 @@ function MenuPopoverSurface({
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose} onDismiss={menu.flushPendingSelect}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose}>
|
||||
<>
|
||||
<AnchoredSurface
|
||||
open={menu.open}
|
||||
@@ -224,7 +227,7 @@ function MenuPopoverSurface({
|
||||
scrollable={scrollable}
|
||||
testID={testID}
|
||||
>
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</AnchoredSurface>
|
||||
{openPages.map(({ page, depth }) => (
|
||||
<MenuFlyout
|
||||
@@ -291,7 +294,7 @@ function MenuFlyout({
|
||||
onPointerLeave={handleHoverOut}
|
||||
testID={testID}
|
||||
>
|
||||
<MenuDepthProvider value={depth + 1}>{page.content}</MenuDepthProvider>
|
||||
<MenuPage depth={depth + 1}>{page.content}</MenuPage>
|
||||
</AnchoredSurface>
|
||||
);
|
||||
}
|
||||
@@ -304,6 +307,17 @@ 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({
|
||||
@@ -355,7 +369,7 @@ function MenuSheetSurface({
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
contentContainerStyle={sheetScrollContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
@@ -363,12 +377,12 @@ function MenuSheetSurface({
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
<MenuPage depth={depth}>{openPage.content}</MenuPage>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
@@ -406,9 +420,6 @@ function MenuSheetHeader({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sheetScrollContent: {
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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} />;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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>;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
@@ -48,8 +46,6 @@ 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";
|
||||
@@ -164,8 +160,6 @@ 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;
|
||||
@@ -225,55 +219,6 @@ 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,
|
||||
@@ -289,73 +234,9 @@ 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>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger
|
||||
@@ -378,6 +259,7 @@ function AttachmentDropdown({
|
||||
offset={8}
|
||||
minWidth={220}
|
||||
testID="message-input-attachment-menu"
|
||||
sheetTitle={addAttachmentLabel}
|
||||
>
|
||||
<AttachmentMenuList items={attachmentMenuItems} />
|
||||
</DropdownMenuContent>
|
||||
@@ -1941,31 +1823,6 @@ 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,3 +2,6 @@ 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,7 +10,6 @@ import {
|
||||
createSetAgentInitializing,
|
||||
refreshAgentInitializationTimeout,
|
||||
} from "@/hooks/use-agent-initialization";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
createSessionAgentStreamReducerQueue,
|
||||
@@ -494,19 +493,6 @@ 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,
|
||||
@@ -739,19 +725,6 @@ 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({
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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,11 +21,13 @@ 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ 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 { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
|
||||
import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import {
|
||||
normalizeProvidersSnapshotCwd,
|
||||
providersSnapshotQueryKey,
|
||||
providersSnapshotQueryRoot,
|
||||
} from "@/data/providers-snapshot";
|
||||
|
||||
type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -184,6 +189,7 @@ export function applyProvidersSnapshotUpdate(input: {
|
||||
serverId: string;
|
||||
queryClient: QueryClient;
|
||||
message: ProvidersSnapshotUpdate;
|
||||
cache?: ProviderSnapshotCache;
|
||||
}): void {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
@@ -194,6 +200,16 @@ 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,6 +3,8 @@ 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 {
|
||||
@@ -79,6 +81,21 @@ 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"]);
|
||||
@@ -95,7 +112,7 @@ describe("fetchProvidersSnapshot", () => {
|
||||
it("sends no cwd for the home scope", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
|
||||
await fetchProvidersSnapshot({ client, cwd: null });
|
||||
await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() });
|
||||
|
||||
expect(client.getCalls).toEqual([{}]);
|
||||
});
|
||||
@@ -103,10 +120,78 @@ describe("fetchProvidersSnapshot", () => {
|
||||
it("sends the workspace cwd for the workspace scope", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
|
||||
await fetchProvidersSnapshot({ client, cwd: "/repo-a" });
|
||||
await fetchProvidersSnapshot({
|
||||
client,
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -127,6 +212,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
serverId,
|
||||
cwd: null,
|
||||
providers: ["codex"],
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]);
|
||||
@@ -147,6 +233,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
providers: ["codex"],
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]);
|
||||
@@ -166,6 +253,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: null,
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -186,6 +274,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe(
|
||||
@@ -209,6 +298,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
cache: createCache(),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(commandsKey)?.isInvalidated).toBe(true);
|
||||
@@ -266,6 +356,27 @@ 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,6 +8,11 @@ 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,
|
||||
@@ -29,9 +34,31 @@ export type ProvidersSnapshotClient = Pick<
|
||||
|
||||
export async function fetchProvidersSnapshot(input: {
|
||||
client: ProvidersSnapshotClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
cache?: ProviderSnapshotCache;
|
||||
}): Promise<GetProvidersSnapshotResult> {
|
||||
return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd }));
|
||||
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;
|
||||
}
|
||||
|
||||
export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
@@ -40,11 +67,17 @@ 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, cwd: input.cwd });
|
||||
const snapshot = await fetchProvidersSnapshot({
|
||||
client: input.client,
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
cache: input.cache,
|
||||
});
|
||||
input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot);
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
@@ -113,10 +146,10 @@ export function useProvidersSnapshot(
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!client || !serverId) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return fetchProvidersSnapshot({ client, cwd });
|
||||
return fetchProvidersSnapshot({ client, serverId, cwd });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,6 +213,6 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: Infinity,
|
||||
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
|
||||
queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
}
|
||||
@@ -56,6 +56,7 @@ 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";
|
||||
@@ -483,6 +484,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
@@ -521,6 +523,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
}),
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
mountClientHandlers: ({ client, host }) => {
|
||||
@@ -1341,7 +1344,6 @@ 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;
|
||||
@@ -1589,12 +1591,11 @@ 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.onAgentStoppedRunning(newServerId, agentId),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
@@ -1977,7 +1978,7 @@ export class HostRuntimeStore {
|
||||
this.directorySyncByServer.set(
|
||||
host.serverId,
|
||||
new DirectorySync(host.serverId, {
|
||||
onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(host.serverId, agentId),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
@@ -2176,18 +2177,6 @@ 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 () => {
|
||||
@@ -2296,13 +2285,6 @@ 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,7 +1359,9 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
style={badgePressableStyle}
|
||||
testID="host-picker-trigger"
|
||||
>
|
||||
<HostStatusDot serverId={host.selectedServerId} />
|
||||
<View style={styles.badgeIconBox}>
|
||||
<HostStatusDot serverId={host.selectedServerId} />
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
|
||||
@@ -11,7 +11,12 @@ type WorkspaceHeaderRenderState =
|
||||
kind: "ready";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
shouldShowSubtitle: boolean;
|
||||
/**
|
||||
* 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;
|
||||
isGitCheckout: boolean;
|
||||
currentBranchName: string | null;
|
||||
};
|
||||
@@ -65,7 +70,7 @@ export function resolveWorkspaceHeaderRenderState(input: {
|
||||
kind: "ready",
|
||||
title: header.title,
|
||||
subtitle: header.subtitle,
|
||||
shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle),
|
||||
isSubtitleDistinct: !areHeaderLabelsEquivalent(header.title, header.subtitle),
|
||||
isGitCheckout: checkout?.isGit ?? false,
|
||||
currentBranchName,
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
PanelRight,
|
||||
@@ -45,6 +44,8 @@ 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";
|
||||
@@ -73,6 +74,7 @@ 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,
|
||||
@@ -244,7 +246,6 @@ 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);
|
||||
@@ -938,6 +939,12 @@ 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}
|
||||
@@ -1043,18 +1050,16 @@ function HeaderMenuProfileItem({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHeaderMenuTriggerIcon({
|
||||
hovered,
|
||||
open,
|
||||
isMobile,
|
||||
}: {
|
||||
hovered: boolean;
|
||||
open: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
|
||||
/**
|
||||
* 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 }) {
|
||||
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
|
||||
return <Icon size={16} uniProps={colorMapping} />;
|
||||
return <ThemedEllipsis size={16} uniProps={colorMapping} />;
|
||||
}
|
||||
|
||||
function WorkspaceHeaderMenu({
|
||||
@@ -1095,22 +1100,28 @@ function WorkspaceHeaderMenu({
|
||||
|
||||
const renderTriggerIcon = useCallback(
|
||||
({ hovered, open }: { hovered: boolean; open: boolean }) => (
|
||||
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} />
|
||||
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} />
|
||||
),
|
||||
[isMobile],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<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">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
sheetTitle={t("workspace.header.actions.workspaceActions")}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-agent"
|
||||
leading={menuNewAgentIcon}
|
||||
@@ -1194,11 +1205,52 @@ 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;
|
||||
showSubtitle: boolean;
|
||||
isSubtitleDistinct: boolean;
|
||||
currentBranchName: string | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -1233,7 +1285,7 @@ function WorkspaceHeaderTitleBar({
|
||||
isLoading,
|
||||
title,
|
||||
subtitle,
|
||||
showSubtitle,
|
||||
isSubtitleDistinct,
|
||||
currentBranchName,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -1271,16 +1323,14 @@ function WorkspaceHeaderTitleBar({
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||
{showSubtitle ? (
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScreenTitle testID="workspace-header-title" style={styles.headerTitle}>
|
||||
{title}
|
||||
</ScreenTitle>
|
||||
<WorkspaceHeaderProjectRow
|
||||
subtitle={subtitle}
|
||||
isSubtitleDistinct={isSubtitleDistinct}
|
||||
serverId={normalizedServerId}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.compactHeaderMenuCluster}>
|
||||
@@ -1410,7 +1460,7 @@ interface WorkspaceHeaderFields {
|
||||
isWorkspaceHeaderLoading: boolean;
|
||||
workspaceHeaderTitle: string;
|
||||
workspaceHeaderSubtitle: string;
|
||||
shouldShowWorkspaceHeaderSubtitle: boolean;
|
||||
isWorkspaceHeaderSubtitleDistinct: boolean;
|
||||
isGitCheckout: boolean;
|
||||
currentBranchName: string | null;
|
||||
}
|
||||
@@ -1445,7 +1495,7 @@ function deriveWorkspaceHeaderFields(input: {
|
||||
isWorkspaceHeaderLoading: true,
|
||||
workspaceHeaderTitle: "",
|
||||
workspaceHeaderSubtitle: "",
|
||||
shouldShowWorkspaceHeaderSubtitle: false,
|
||||
isWorkspaceHeaderSubtitleDistinct: false,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
};
|
||||
@@ -1454,7 +1504,7 @@ function deriveWorkspaceHeaderFields(input: {
|
||||
isWorkspaceHeaderLoading: false,
|
||||
workspaceHeaderTitle: renderState.title,
|
||||
workspaceHeaderSubtitle: renderState.subtitle,
|
||||
shouldShowWorkspaceHeaderSubtitle: renderState.shouldShowSubtitle,
|
||||
isWorkspaceHeaderSubtitleDistinct: renderState.isSubtitleDistinct,
|
||||
isGitCheckout: renderState.isGitCheckout,
|
||||
currentBranchName: renderState.currentBranchName,
|
||||
};
|
||||
@@ -1929,7 +1979,7 @@ function WorkspaceScreenContent({
|
||||
isWorkspaceHeaderLoading,
|
||||
workspaceHeaderTitle,
|
||||
workspaceHeaderSubtitle,
|
||||
shouldShowWorkspaceHeaderSubtitle,
|
||||
isWorkspaceHeaderSubtitleDistinct,
|
||||
isGitCheckout,
|
||||
currentBranchName,
|
||||
} = deriveWorkspaceHeaderFields({
|
||||
@@ -3760,7 +3810,7 @@ function WorkspaceScreenContent({
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
|
||||
currentBranchName={currentBranchName}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -3925,14 +3975,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: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
md: "300",
|
||||
fontSize: {
|
||||
xs: theme.fontSize.sm,
|
||||
md: theme.fontSize.base,
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flex: 1,
|
||||
@@ -3968,15 +4018,29 @@ 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.sm,
|
||||
xs: theme.fontSize.xs,
|
||||
md: theme.fontSize.base,
|
||||
},
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: "60%",
|
||||
},
|
||||
headerProjectSeparator: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerTitleSkeleton: {
|
||||
width: 220,
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("workspace source of truth consumption", () => {
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isSubtitleDistinct: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe("workspace source of truth consumption", () => {
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "Local folders",
|
||||
shouldShowSubtitle: true,
|
||||
isSubtitleDistinct: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe("workspace source of truth consumption", () => {
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isSubtitleDistinct: true,
|
||||
isGitCheckout: true,
|
||||
currentBranchName: "feat/workspace-sot",
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe("workspace source of truth consumption", () => {
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "notes",
|
||||
shouldShowSubtitle: false,
|
||||
isSubtitleDistinct: false,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
@@ -185,7 +185,7 @@ describe("workspace source of truth consumption", () => {
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isSubtitleDistinct: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ const FLAG_STRIKETHROUGH = 1 << 5;
|
||||
interface ResolvedCellStyle {
|
||||
key: string;
|
||||
style: TextStyle;
|
||||
foregroundColor: string;
|
||||
}
|
||||
|
||||
interface ResolvedCellColors {
|
||||
@@ -56,6 +57,11 @@ 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 }, () => "");
|
||||
|
||||
@@ -222,7 +228,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
|
||||
});
|
||||
const cachedStyle = styleCache.get(key);
|
||||
if (cachedStyle) {
|
||||
return { key, style: cachedStyle };
|
||||
return { key, style: cachedStyle, foregroundColor: colors.foreground };
|
||||
}
|
||||
|
||||
const style = createTextStyle({
|
||||
@@ -232,7 +238,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
|
||||
flags,
|
||||
});
|
||||
styleCache.set(key, style);
|
||||
return { key, style };
|
||||
return { key, style, foregroundColor: colors.foreground };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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,12 +1,23 @@
|
||||
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,6 +21,19 @@ 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,23 +9,32 @@ 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 } from "./colors";
|
||||
import {
|
||||
createTerminalCellStyleResolver,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
NATIVE_TERMINAL_SELECTION_COLORS,
|
||||
} from "./colors";
|
||||
import type { TerminalGlyphRect } from "./terminal-custom-glyph";
|
||||
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 {
|
||||
resolveTerminalSelectionRects,
|
||||
type TerminalSelectionRange,
|
||||
type TerminalSelectionRect,
|
||||
} from "./terminal-selection";
|
||||
buildRows,
|
||||
type TerminalCustomGlyphCell,
|
||||
type TerminalCustomGlyphRun,
|
||||
type TerminalRowModel,
|
||||
type TerminalRun,
|
||||
} from "./terminal-row-model";
|
||||
import type { TerminalSelectionRange } from "./terminal-selection";
|
||||
|
||||
const MEASURE_TEXT = "mmmmmmmmmm";
|
||||
const DEFAULT_FONT_SIZE = 12;
|
||||
@@ -58,11 +67,6 @@ interface TerminalGridRunProps {
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
interface TerminalGridSelectionRectProps {
|
||||
rect: TerminalSelectionRect;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TerminalGridViewProps {
|
||||
state: TerminalViewportState;
|
||||
xtermTheme?: ITheme;
|
||||
@@ -114,9 +118,104 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
|
||||
|
||||
return (
|
||||
<View style={runStyle}>
|
||||
<Text numberOfLines={1} style={runTextStyle}>
|
||||
{run.text}
|
||||
</Text>
|
||||
{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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -130,27 +229,6 @@ 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,
|
||||
@@ -244,23 +322,21 @@ export function TerminalGridView({
|
||||
[state.grid, visibleCols],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildRows({ grid: projectedGrid, resolver }),
|
||||
[projectedGrid, resolver],
|
||||
);
|
||||
const selectionRects = useMemo(
|
||||
() =>
|
||||
resolveTerminalSelectionRects({
|
||||
selection,
|
||||
viewport: {
|
||||
firstRow: state.firstRow,
|
||||
rows: state.grid.length,
|
||||
cols: visibleCols,
|
||||
},
|
||||
metrics,
|
||||
buildRows({
|
||||
grid: projectedGrid,
|
||||
resolver,
|
||||
selection: selection
|
||||
? {
|
||||
range: selection,
|
||||
firstRow: state.firstRow,
|
||||
backgroundColor: NATIVE_TERMINAL_SELECTION_COLORS.background,
|
||||
foregroundColor: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
[metrics, selection, state.firstRow, state.grid.length, visibleCols],
|
||||
[projectedGrid, resolver, selection, state.firstRow],
|
||||
);
|
||||
const selectionColor = xtermTheme.selectionBackground ?? "rgba(90, 160, 255, 0.35)";
|
||||
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor: resolver.backgroundColor }, style],
|
||||
@@ -346,9 +422,6 @@ 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>
|
||||
@@ -368,6 +441,15 @@ const styles = StyleSheet.create({
|
||||
run: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
customGlyphRun: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
customGlyphSvg: {
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
},
|
||||
rowText: {
|
||||
includeFontPadding: false,
|
||||
margin: 0,
|
||||
@@ -384,10 +466,4 @@ 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: true,
|
||||
shouldClear: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: true });
|
||||
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false });
|
||||
|
||||
input.reset();
|
||||
|
||||
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: true });
|
||||
expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false });
|
||||
});
|
||||
|
||||
it("translates software keyboard terminal control keys", () => {
|
||||
@@ -197,14 +197,35 @@ describe("native terminal typed input", () => {
|
||||
|
||||
expect(input.receiveTextChange("echo clipboard item")).toEqual({
|
||||
data: "echo clipboard item",
|
||||
shouldClear: true,
|
||||
shouldClear: false,
|
||||
});
|
||||
|
||||
input.reset();
|
||||
|
||||
expect(input.receiveTextChange("echo clipboard item")).toEqual({
|
||||
data: "echo clipboard item",
|
||||
shouldClear: true,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,10 +107,7 @@ export function createTerminalTextInputState(): TerminalTextInputState {
|
||||
previousText = text;
|
||||
return {
|
||||
data: appendedText,
|
||||
// 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,
|
||||
shouldClear: false,
|
||||
};
|
||||
},
|
||||
reset(): void {
|
||||
|
||||
@@ -40,4 +40,81 @@ 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,6 +2,8 @@ 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],
|
||||
@@ -16,12 +18,30 @@ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1f300, 0x1faff],
|
||||
];
|
||||
|
||||
export interface TerminalRun {
|
||||
interface TerminalRunBase {
|
||||
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 {
|
||||
@@ -32,6 +52,13 @@ 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) {
|
||||
@@ -75,28 +102,88 @@ 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) {
|
||||
if (
|
||||
previousRun &&
|
||||
previousRun.styleKey === input.styleKey &&
|
||||
previousRun.renderKind === renderKind
|
||||
) {
|
||||
const offset = previousRun.cellCount;
|
||||
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;
|
||||
}
|
||||
|
||||
input.runs.push({
|
||||
const baseRun: TerminalRunBase = {
|
||||
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;
|
||||
@@ -106,17 +193,42 @@ 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: resolvedStyle.key,
|
||||
style: resolvedStyle.style,
|
||||
styleKey,
|
||||
style,
|
||||
foregroundColor,
|
||||
customGlyph,
|
||||
col,
|
||||
});
|
||||
hash = hashStringPart(hash, text);
|
||||
hash = hashStringPart(hash, String(cellCount));
|
||||
hash = hashStringPart(hash, resolvedStyle.key);
|
||||
hash = hashStringPart(hash, styleKey);
|
||||
hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text");
|
||||
|
||||
if (cellCount > 1) {
|
||||
col += 1;
|
||||
@@ -133,8 +245,9 @@ 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 }),
|
||||
buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -276,6 +276,26 @@ 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,6 +2715,49 @@ 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,39 +190,6 @@ 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,7 +33,6 @@ 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;
|
||||
}
|
||||
@@ -451,15 +450,6 @@ 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, {
|
||||
|
||||
@@ -616,6 +616,30 @@ 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([
|
||||
{
|
||||
|
||||
@@ -1573,16 +1573,14 @@ 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) => ({
|
||||
kind: "assistant_message",
|
||||
...activeItem,
|
||||
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,6 +134,27 @@ 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,7 +97,10 @@ export class DaemonConnectionTestError extends Error {
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
options?: { capabilities?: DaemonClientConfig["capabilities"] },
|
||||
options?: {
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
@@ -112,6 +115,7 @@ 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 }
|
||||
@@ -224,6 +228,7 @@ interface ProbeOptions {
|
||||
serverId?: string;
|
||||
timeoutMs?: number;
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
}
|
||||
|
||||
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
test: {
|
||||
name: "unit",
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"],
|
||||
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.5",
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/server": "0.2.5",
|
||||
"@getpaseo/client": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/server": "0.3.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0-beta.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/relay": "0.2.5",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/relay": "0.3.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -59,7 +60,9 @@ export function normalizeListProviderModelsPayload(
|
||||
export function normalizeProvidersSnapshotPayload<
|
||||
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
|
||||
>(payload: T): T {
|
||||
const entries = normalizeProviderSnapshotEntries(payload.entries);
|
||||
const entries = payload.compactSnapshot
|
||||
? expandProviderSnapshot(payload.compactSnapshot)
|
||||
: normalizeProviderSnapshotEntries(payload.entries);
|
||||
return entries === payload.entries ? payload : { ...payload, entries };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
|
||||
import {
|
||||
DaemonClient,
|
||||
type DaemonClientTrace,
|
||||
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 {
|
||||
@@ -34,6 +39,24 @@ 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> = [];
|
||||
|
||||
@@ -161,6 +184,56 @@ 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:
|
||||
@@ -664,6 +737,7 @@ 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,
|
||||
@@ -4384,6 +4458,81 @@ 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();
|
||||
|
||||
@@ -317,9 +317,16 @@ 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 }>;
|
||||
@@ -1035,6 +1042,26 @@ 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) {
|
||||
@@ -1535,6 +1562,49 @@ 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.
|
||||
@@ -1549,7 +1619,7 @@ export class DaemonClient {
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -1566,7 +1636,11 @@ export class DaemonClient {
|
||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||
}
|
||||
try {
|
||||
this.transport.send(frame);
|
||||
this.traceInstant("paseo.ws.message.outbound", {
|
||||
envelopeType: "binary",
|
||||
messageType: "binary",
|
||||
});
|
||||
this.sendTransportFrame(frame);
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -1587,7 +1661,7 @@ export class DaemonClient {
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -1623,7 +1697,7 @@ export class DaemonClient {
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(pending.message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
pending.resolve();
|
||||
} else {
|
||||
pending.reject(new Error("Connection lost before message could be sent"));
|
||||
@@ -1777,7 +1851,7 @@ export class DaemonClient {
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -1952,7 +2026,7 @@ export class DaemonClient {
|
||||
this.pingProbe = probe;
|
||||
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "ping" }));
|
||||
this.sendJsonMessage("ping", "ping", { type: "ping" });
|
||||
} catch (error) {
|
||||
this.clearPingProbe();
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -4421,6 +4495,7 @@ export class DaemonClient {
|
||||
|
||||
async getProvidersSnapshot(options?: {
|
||||
cwd?: string;
|
||||
ifNoneMatch?: string;
|
||||
requestId?: string;
|
||||
}): Promise<GetProvidersSnapshotPayload> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
@@ -4428,6 +4503,7 @@ export class DaemonClient {
|
||||
message: {
|
||||
type: "get_providers_snapshot_request",
|
||||
cwd: options?.cwd,
|
||||
ifNoneMatch: options?.ifNoneMatch,
|
||||
},
|
||||
responseType: "get_providers_snapshot_response",
|
||||
});
|
||||
@@ -5326,23 +5402,22 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
try {
|
||||
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 } : {}),
|
||||
}),
|
||||
);
|
||||
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 } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to send hello message";
|
||||
this.lastErrorValue = message;
|
||||
@@ -5413,24 +5488,37 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
const rawBytes = asUint8Array(rawData);
|
||||
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -5457,11 +5545,19 @@ 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);
|
||||
@@ -5473,6 +5569,11 @@ 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);
|
||||
@@ -5483,6 +5584,11 @@ 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user