mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3751f1bf | ||
|
|
cb4976289b | ||
|
|
2668f31e07 | ||
|
|
be523e09e9 | ||
|
|
1c41ba0bee | ||
|
|
90aa8bbefc | ||
|
|
c28836b4e5 | ||
|
|
8f38d04919 | ||
|
|
ce7b3a90c0 | ||
|
|
cefb71dcac | ||
|
|
49b257c55a | ||
|
|
1e0ce69c54 | ||
|
|
b9b3a24d58 | ||
|
|
b19db9537c | ||
|
|
1a117652fd | ||
|
|
638693a5d7 | ||
|
|
2740c8206f | ||
|
|
0906d1d7bd | ||
|
|
3d420720c5 | ||
|
|
375d1d6fb8 | ||
|
|
f0eb7cea4b | ||
|
|
b669dad221 | ||
|
|
7ac4303d8f | ||
|
|
e36842b234 | ||
|
|
7392e1b767 | ||
|
|
3196385526 | ||
|
|
b167ee02e1 | ||
|
|
cc5ce1038d | ||
|
|
8d3881f640 | ||
|
|
a7c7a6e482 | ||
|
|
082ddb2303 | ||
|
|
dc19b45f23 | ||
|
|
98d3281cad | ||
|
|
5e197ad0cf | ||
|
|
9b5f6f33f6 | ||
|
|
3ddebe7221 |
@@ -1,13 +1,9 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release beta
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
|
||||
Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
@@ -6,8 +6,4 @@ user-invocable: true
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
|
||||
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
+38
-58
@@ -1,79 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0-beta.3 - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
|
||||
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
|
||||
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
|
||||
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
|
||||
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
|
||||
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
|
||||
- Copying part of an assistant response preserves its formatting and selection boundaries ([#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
|
||||
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
|
||||
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
|
||||
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct connections keep working through the browser relay ([#2976](https://github.com/getpaseo/paseo/pull/2976))
|
||||
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
|
||||
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
|
||||
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
|
||||
- Windows terminals handle deferred startup failures without crashing
|
||||
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
|
||||
- New Workspace keeps its launch selector alongside mobile controls
|
||||
- Images stay anchored while a conversation reloads
|
||||
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
|
||||
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
|
||||
|
||||
## 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
|
||||
## 0.3.0 - 2026-08-08
|
||||
|
||||
### 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))
|
||||
- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711))
|
||||
- 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))
|
||||
- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995))
|
||||
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
|
||||
- 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))
|
||||
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
|
||||
- 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))
|
||||
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
|
||||
- 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))
|
||||
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
|
||||
- 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))
|
||||
- Provider data is cached in the client for a faster startup
|
||||
- Recent chats stay live in the background, so switching back shows current messages right away ([#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))
|
||||
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
|
||||
- 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))
|
||||
- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
|
||||
- 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))
|
||||
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
|
||||
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
|
||||
- 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))
|
||||
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
|
||||
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
|
||||
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
|
||||
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
|
||||
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
|
||||
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
|
||||
- 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))
|
||||
@@ -81,20 +51,30 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
|
||||
- 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))
|
||||
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
|
||||
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
|
||||
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
|
||||
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
|
||||
- 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))
|
||||
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
|
||||
- Windows terminals handle deferred startup failures without crashing
|
||||
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
|
||||
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
|
||||
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
|
||||
- Images stay anchored while a conversation reloads
|
||||
- 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))
|
||||
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
|
||||
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
|
||||
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
|
||||
- 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))
|
||||
|
||||
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
@@ -57,6 +58,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
### Writing docs
|
||||
|
||||
|
||||
@@ -87,6 +87,14 @@ or parking changes that surface's geometry without reparenting the webview. The
|
||||
uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and
|
||||
`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to
|
||||
1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`.
|
||||
Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface
|
||||
has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron
|
||||
exposes the 1x1 parking geometry as a real guest resize before expanding it again.
|
||||
|
||||
The permanent browser host and `overlay-root` are explicit sibling paint planes. The browser
|
||||
plane stays below the overlay plane regardless of body insertion order; menus keep their relative
|
||||
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
|
||||
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
|
||||
+1
-1
@@ -341,7 +341,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
|
||||
### Nested: ScheduleRun
|
||||
|
||||
|
||||
+36
-12
@@ -2,7 +2,9 @@
|
||||
|
||||
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect <url> --token <token>` from the daemon machine.
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -44,12 +46,30 @@ daemon, execution, and agent identity with that terminal state. Paseo never stor
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
Hub creates use the same agent creation path as trusted clients. They may select any existing
|
||||
worktree target shape and carry optional MCP server configuration for the agent session. The daemon
|
||||
keeps that configuration in its private agent record so provider sessions can recover after a
|
||||
restart; neither ordinary client snapshots and updates nor Hub projections expose session
|
||||
configuration. Execution completion policy remains outside the daemon: a completed agent turn does
|
||||
not imply that the Hub execution is terminal.
|
||||
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
|
||||
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
|
||||
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
|
||||
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
|
||||
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
|
||||
record so provider sessions can recover after a restart; neither ordinary client snapshots and
|
||||
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
|
||||
supported provider keys.
|
||||
|
||||
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
|
||||
Every reference must name an MCP server injected by the same create request. The daemon translates
|
||||
only those identities into the selected provider's native approval configuration. The protocol
|
||||
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
|
||||
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
|
||||
Hub creation instead of broadening access or waiting for an invisible prompt.
|
||||
When a create request includes a tool policy, a successful response includes
|
||||
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
|
||||
|
||||
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
|
||||
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
|
||||
and the provider's local or managed policy remain the authorization controls.
|
||||
|
||||
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
|
||||
the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
@@ -59,11 +79,11 @@ If no execution exists for that authenticated daemon and execution ID, interrupt
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
|
||||
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
|
||||
workspace through the shared workspace archive service, so the backing directory is removed only
|
||||
after its final active workspace reference disappears. Local and shared checkouts archive only the
|
||||
execution-owned agent.
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
|
||||
required workspaceId and sends it through the shared workspace archive service. The service archives
|
||||
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
|
||||
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
|
||||
sharing a backing directory remain active.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
@@ -81,9 +101,13 @@ opening a Hub socket. This also covers an enrollment whose request may have succ
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
## Cross-repository compatibility
|
||||
|
||||
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
|
||||
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
|
||||
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
|
||||
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
|
||||
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
|
||||
custom ACP providers remain unsupported for unattended preapproval.
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
|
||||
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
|
||||
|
||||
## Configure a directory source
|
||||
|
||||
Add the plugin to the root `plugins` object in `$PASEO_HOME/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"my-plugin": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/my-plugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The directory contains an identity-only manifest and one entry point:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.tsx
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The manifest ID must match the config key. Restart the development daemon after changing plugin
|
||||
source or configuration; directory watching is outside the first slice.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
|
||||
removes UI registrations from the server bundle and RPC registrations from the client bundle before
|
||||
resolving dependencies.
|
||||
|
||||
```tsx
|
||||
import { Text } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const greetRpc = defineRpc({
|
||||
name: "greet",
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ message: z.string() }),
|
||||
});
|
||||
|
||||
function Greeting() {
|
||||
const greet = useRpc(greetRpc);
|
||||
const query = useQuery({
|
||||
queryKey: ["greeting", "Paseo"],
|
||||
queryFn: () => greet({ name: "Paseo" }),
|
||||
});
|
||||
return <Text>{query.data?.message}</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Greeting",
|
||||
icon: "MessageCircle",
|
||||
surface: "main",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```tsx
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const issues = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Acme issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Acme issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
plugin.addAttachmentSource(issues);
|
||||
}
|
||||
```
|
||||
|
||||
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
|
||||
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `examples/local-plugin` for a native surface and `extensions/linear` for a complete official
|
||||
attachment-source extension.
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
- **Codex:** `approval_policy`, `sandbox_mode`,
|
||||
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
|
||||
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
|
||||
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
|
||||
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
|
||||
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
|
||||
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
|
||||
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
|
||||
application policy, not an OS sandbox.
|
||||
|
||||
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
+111
-45
@@ -9,19 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
|
||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
- commit the approved release inputs locally
|
||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
||||
- create the release heartbeat immediately and babysit it to completion
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
@@ -31,12 +29,43 @@ Rules that apply to both steps:
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Release source and CI
|
||||
|
||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
||||
resolved commit. The default release checkout is a clean local `main` whose
|
||||
`HEAD` equals `origin/main`.
|
||||
|
||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
||||
commit instead of `origin/main`.
|
||||
|
||||
Before making release-preparation commits, confirm the existing CI run for the
|
||||
resolved commit is green. Pending CI is watched to completion. Release
|
||||
preparation then stays local through the changelog, any explicitly requested ACP
|
||||
catalog update, lockfile preparation, and the version commit. After approval,
|
||||
commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
|
||||
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
|
||||
run `npm run acp:version-drift:update`, verify the catalog, and include the
|
||||
update in the local release-preparation commits.
|
||||
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
|
||||
include the update in the local release-preparation commits.
|
||||
|
||||
The release authorization covers the requested ACP commit. It ships in the same
|
||||
release push as the changelog and version commit.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never become the website's default download — they sit behind the Stable/Beta switch on `/download`.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
@@ -48,8 +77,9 @@ pointers. The npm invariant is:
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
diff. The highest-impact change determines the version:
|
||||
Every fresh release starts by classifying the full diff from the previous
|
||||
stable to the resolved release source. The highest-impact change determines the
|
||||
version:
|
||||
|
||||
- **Minor** — a user would experience the release as a significant upgrade. This
|
||||
includes substantial new workflows, providers, forges, platforms, integrations,
|
||||
@@ -72,7 +102,7 @@ as described in **Fixing a failed release build**.
|
||||
|
||||
Before running any stable release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
@@ -133,7 +163,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `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.
|
||||
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -291,11 +321,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
## Release completion and heartbeat
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
A release is **in progress** after npm publication and tag push. Report it as
|
||||
**shipped** only after every applicable build, publication, asset, manifest, and
|
||||
store submission passes the completion checklist.
|
||||
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
Immediately after every beta, stable, or promotion tag push, create a heartbeat
|
||||
that resumes the release in the current conversation. Create it automatically
|
||||
with `create_heartbeat`. The heartbeat owns the release until it either reaches
|
||||
the completion checklist or finds a failure that needs new user authority.
|
||||
|
||||
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
|
||||
release branch and tag, npm dist-tags, the GitHub Release body and assets,
|
||||
desktop updater manifests, the published Docker image, and the applicable EAS
|
||||
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
|
||||
Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -303,13 +352,16 @@ Pattern:
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
"cron": "*/10 * * * *",
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
|
||||
Run an immediate status check after creating the heartbeat. The heartbeat handles
|
||||
later transitions and stops itself when the release is complete.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
@@ -322,7 +374,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion rewrites the `-beta.N` entry in place, so that anchor dies while the group survives. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -403,17 +455,16 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
|
||||
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
|
||||
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
|
||||
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -434,10 +485,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -480,7 +529,7 @@ Rules:
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
@@ -494,51 +543,68 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
|
||||
Review the diff between the latest release tag and `HEAD`. Focus on:
|
||||
Review the diff between the latest release tag and the resolved release source. Focus on:
|
||||
|
||||
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
|
||||
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
|
||||
Changelog scope follows the release being described:
|
||||
|
||||
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
|
||||
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
- **First beta**: `previous stable tag → release source`
|
||||
- **Later beta**: `previous beta tag → release source`
|
||||
- **Direct stable release**: `previous stable tag → release source`
|
||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
||||
|
||||
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
|
||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const incrementRpc = defineRpc({
|
||||
name: "increment",
|
||||
input: z.object({ value: z.number() }),
|
||||
output: z.object({ value: z.number(), handledBy: z.string() }),
|
||||
});
|
||||
|
||||
interface ExampleTheme {
|
||||
colors: {
|
||||
surface0: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
accent: string;
|
||||
accentForeground: string;
|
||||
statusDanger: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ExampleSurface({ theme }: { theme: ExampleTheme }) {
|
||||
const callIncrement = useRpc(incrementRpc);
|
||||
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
|
||||
const value = data?.value ?? 0;
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: theme.colors.surface0 },
|
||||
title: { color: theme.colors.foreground, fontSize: 24 },
|
||||
detail: { color: theme.colors.foregroundMuted },
|
||||
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
|
||||
buttonText: { color: theme.colors.accentForeground, textAlign: "center" },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
}),
|
||||
[theme.colors],
|
||||
);
|
||||
const handleIncrement = useCallback(() => {
|
||||
mutate({ value });
|
||||
}, [mutate, value]);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.title}>Native plugin surface</Text>
|
||||
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Increment plugin counter, currently ${value}`}
|
||||
onPress={handleIncrement}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isPending ? "Calling daemon…" : `RPC counter: ${value}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{error ? <Text style={styles.error}>{error.message}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(incrementRpc, async (input) => ({
|
||||
value: input.value + 1,
|
||||
handledBy: "plugin subprocess",
|
||||
}));
|
||||
plugin.addSurface("main", ExampleSurface);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Local plugin",
|
||||
icon: "Blocks",
|
||||
surface: "main",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "local-example"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Linear
|
||||
|
||||
The official Linear extension adds `Attach Linear issue` to the composer. Search by issue identifier
|
||||
or title, then send a stable issue snapshot to the agent.
|
||||
|
||||
Set a personal API key in the daemon environment:
|
||||
|
||||
```bash
|
||||
export LINEAR_API_KEY="lin_api_..."
|
||||
```
|
||||
|
||||
Register the extension in `$PASEO_HOME/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"linear": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/paseo/extensions/linear"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the development daemon after changing its environment or plugin configuration.
|
||||
|
||||
The entry point demonstrates the complete attachment-source pattern:
|
||||
|
||||
- define one validated search RPC;
|
||||
- handle it in the daemon subprocess;
|
||||
- register a declarative attachment source for the client;
|
||||
- keep credentials and vendor API calls out of the client bundle.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentSearchPayload,
|
||||
type PluginContext,
|
||||
} from "@paseo/plugin";
|
||||
import { createLinearIssueSearch } from "./linear";
|
||||
|
||||
const SearchPayloadSchema = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: SearchPayloadSchema,
|
||||
});
|
||||
|
||||
const issueAttachments = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Linear issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Linear issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, async ({ query }): Promise<PluginAttachmentSearchPayload> => {
|
||||
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
||||
return linear.search(query);
|
||||
});
|
||||
plugin.addAttachmentSource(issueAttachments);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLinearIssueSearch } from "./linear";
|
||||
|
||||
const issue = {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
description: "Let extensions attach external context.",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
priorityLabel: "High",
|
||||
state: { name: "In Progress" },
|
||||
assignee: { name: "Mohamed" },
|
||||
project: { name: "Paseo" },
|
||||
labels: { nodes: [{ name: "Feature" }] },
|
||||
};
|
||||
|
||||
interface CapturedRequest {
|
||||
authorization: string | undefined;
|
||||
body: { query: string; variables: Record<string, unknown> };
|
||||
}
|
||||
|
||||
async function readRequest(request: IncomingMessage): Promise<CapturedRequest> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
return {
|
||||
authorization: request.headers.authorization,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"],
|
||||
};
|
||||
}
|
||||
|
||||
async function withLinearServer<T>(
|
||||
respond: (request: CapturedRequest, response: ServerResponse) => void,
|
||||
run: (endpoint: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const server = createServer((request, response) => {
|
||||
void readRequest(request).then((captured) => respond(captured, response));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
try {
|
||||
return await run(`http://127.0.0.1:${address.port}/graphql`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown): void {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
describe("Linear issue search", () => {
|
||||
it("resolves an exact issue identifier and formats its agent snapshot", async () => {
|
||||
const result = await withLinearServer(
|
||||
(request, response) => {
|
||||
expect(request.authorization).toBe("lin_api_test");
|
||||
expect(request.body.variables).toEqual({ id: "ENG-123" });
|
||||
sendJson(response, { data: { issue } });
|
||||
},
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search("eng-123");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
subtitle: "In Progress · Mohamed",
|
||||
url: issue.url,
|
||||
resourceType: "issue",
|
||||
text: [
|
||||
"Linear issue ENG-123: Plugin attachments",
|
||||
`URL: ${issue.url}`,
|
||||
"Status: In Progress",
|
||||
"Priority: High",
|
||||
"Assignee: Mohamed",
|
||||
"Project: Paseo",
|
||||
"Labels: Feature",
|
||||
"",
|
||||
"Let extensions attach external context.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("searches issue titles through a case-insensitive filter", async () => {
|
||||
const result = await withLinearServer(
|
||||
(request, response) => {
|
||||
expect(request.body.variables).toEqual({
|
||||
filter: { title: { containsIgnoreCase: "plugin" } },
|
||||
});
|
||||
sendJson(response, { data: { issues: { nodes: [issue] } } });
|
||||
},
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search(" plugin ");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]);
|
||||
});
|
||||
|
||||
it("surfaces GraphQL errors even when HTTP succeeds", async () => {
|
||||
await expect(
|
||||
withLinearServer(
|
||||
(_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }),
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search("ENG-123");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("requires a daemon-side API key", () => {
|
||||
expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow(
|
||||
"Set LINEAR_API_KEY in the daemon environment",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LinearIssueSchema = z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
url: z.string(),
|
||||
priorityLabel: z.string(),
|
||||
state: z.object({ name: z.string() }),
|
||||
assignee: z.object({ name: z.string() }).nullable(),
|
||||
project: z.object({ name: z.string() }).nullable(),
|
||||
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
|
||||
});
|
||||
|
||||
const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough();
|
||||
const ExactIssueResponseSchema = z.object({
|
||||
data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(),
|
||||
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
||||
});
|
||||
const IssueSearchResponseSchema = z.object({
|
||||
data: z
|
||||
.object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) })
|
||||
.nullable()
|
||||
.optional(),
|
||||
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
||||
});
|
||||
|
||||
const ISSUE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
priorityLabel
|
||||
state { name }
|
||||
assignee { name }
|
||||
project { name }
|
||||
labels { nodes { name } }
|
||||
`;
|
||||
|
||||
const EXACT_ISSUE_QUERY = `
|
||||
query PaseoLinearIssue($id: String!) {
|
||||
issue(id: $id) { ${ISSUE_FIELDS} }
|
||||
}
|
||||
`;
|
||||
|
||||
const SEARCH_ISSUES_QUERY = `
|
||||
query PaseoLinearIssues($filter: IssueFilter) {
|
||||
issues(first: 20, filter: $filter, orderBy: updatedAt) {
|
||||
nodes { ${ISSUE_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
|
||||
export interface LinearAttachmentItem {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface LinearIssueSearch {
|
||||
search(query: string): Promise<{ items: LinearAttachmentItem[] }>;
|
||||
}
|
||||
|
||||
interface LinearIssueSearchOptions {
|
||||
apiKey: string;
|
||||
endpoint?: string;
|
||||
request?: typeof fetch;
|
||||
}
|
||||
|
||||
interface GraphqlRequest {
|
||||
query: string;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class LinearApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "LinearApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function issueSubtitle(issue: z.infer<typeof LinearIssueSchema>): string | undefined {
|
||||
const parts = [issue.state.name, issue.assignee?.name].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
function issueText(issue: z.infer<typeof LinearIssueSchema>): string {
|
||||
const labels = issue.labels.nodes.map((label) => label.name).join(", ");
|
||||
const lines = [
|
||||
`Linear issue ${issue.identifier}: ${issue.title}`,
|
||||
`URL: ${issue.url}`,
|
||||
`Status: ${issue.state.name}`,
|
||||
`Priority: ${issue.priorityLabel}`,
|
||||
];
|
||||
if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`);
|
||||
if (issue.project) lines.push(`Project: ${issue.project.name}`);
|
||||
if (labels) lines.push(`Labels: ${labels}`);
|
||||
lines.push("", issue.description ?? "No description.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toAttachmentItem(issue: z.infer<typeof LinearIssueSchema>): LinearAttachmentItem {
|
||||
const subtitle = issueSubtitle(issue);
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
...(subtitle ? { subtitle } : {}),
|
||||
url: issue.url,
|
||||
text: issueText(issue),
|
||||
resourceType: "issue",
|
||||
};
|
||||
}
|
||||
|
||||
function describeHttpFailure(status: number): string {
|
||||
if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY";
|
||||
if (status === 429) return "Linear rate limit reached. Try again shortly";
|
||||
return `Linear API request failed with HTTP ${status}`;
|
||||
}
|
||||
|
||||
function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void {
|
||||
if (!errors || errors.length === 0) return;
|
||||
throw new LinearApiError(errors.map((error) => error.message).join("; "));
|
||||
}
|
||||
|
||||
export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch {
|
||||
const apiKey = options.apiKey.trim();
|
||||
if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment");
|
||||
const endpoint = options.endpoint ?? "https://api.linear.app/graphql";
|
||||
const request = options.request ?? fetch;
|
||||
|
||||
async function graphql(body: GraphqlRequest): Promise<unknown> {
|
||||
const response = await request(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function findExact(identifier: string): Promise<LinearAttachmentItem[]> {
|
||||
const response = ExactIssueResponseSchema.parse(
|
||||
await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }),
|
||||
);
|
||||
throwGraphqlErrors(response.errors);
|
||||
return response.data?.issue ? [toAttachmentItem(response.data.issue)] : [];
|
||||
}
|
||||
|
||||
async function searchTitles(query: string): Promise<LinearAttachmentItem[]> {
|
||||
const filter = query ? { title: { containsIgnoreCase: query } } : null;
|
||||
const response = IssueSearchResponseSchema.parse(
|
||||
await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }),
|
||||
);
|
||||
throwGraphqlErrors(response.errors);
|
||||
if (!response.data) throw new LinearApiError("Linear returned no issue data");
|
||||
return response.data.issues.nodes.map(toAttachmentItem);
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query: string) {
|
||||
const normalized = query.trim();
|
||||
if (LINEAR_IDENTIFIER.test(normalized)) {
|
||||
return { items: await findExact(normalized.toUpperCase()) };
|
||||
}
|
||||
const items = await searchTitles(normalized);
|
||||
return { items };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "linear"
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-fEB6zAHMdtGfAJ19LtBzbiVCHqY4IJkmIjsBUe2dCW0=
|
||||
sha256-BfRbfVUFaHQEMtDOsWAo49txz+cueRpe/7Qxltg+HSg=
|
||||
|
||||
Generated
+552
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2291,6 +2291,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/standalone": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.1.tgz",
|
||||
"integrity": "sha512-z42abD0C6fiHfgLyCWw8PYv6FCJ0IGVtSCxXk/NPykWO5LNIEGfdLDJ3HdYqlPcAhwtQ3oKH1PvNj2JGpTxQKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -12503,6 +12512,21 @@
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__standalone": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__standalone/-/babel__standalone-7.1.9.tgz",
|
||||
"integrity": "sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.6",
|
||||
"@babel/types": "^7.25.6",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/babel__generator": "^7.6.8",
|
||||
"@types/babel__template": "^7.4.4",
|
||||
"@types/babel__traverse": "^7.20.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
@@ -35299,8 +35323,9 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.1",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
@@ -35393,6 +35418,7 @@
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/babel__standalone": "^7.1.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
@@ -36353,12 +36379,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.3.0-beta.3",
|
||||
"@getpaseo/protocol": "0.3.0-beta.3",
|
||||
"@getpaseo/server": "0.3.0-beta.3",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/server": "0.3.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36605,10 +36631,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.0-beta.3",
|
||||
"@getpaseo/relay": "0.3.0-beta.3",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36619,7 +36645,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36862,7 +36888,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37758,7 +37784,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37990,7 +38016,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38003,7 +38029,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38221,15 +38247,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.3.0-beta.3",
|
||||
"@getpaseo/highlight": "0.3.0-beta.3",
|
||||
"@getpaseo/protocol": "0.3.0-beta.3",
|
||||
"@getpaseo/relay": "0.3.0-beta.3",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/highlight": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38239,6 +38266,7 @@
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lru-cache": "^11.5.1",
|
||||
@@ -38402,6 +38430,468 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@isaacs/ttlcache": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
|
||||
@@ -38556,6 +39046,47 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
@@ -38778,7 +39309,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
||||
await themeTrigger.click();
|
||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Interface font size");
|
||||
await expect(fontSizeInput).toHaveValue("24");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "9px");
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { addConnectedHostsAndReload } from "../support/helpers/hosts";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
@@ -29,6 +30,7 @@ interface CreatedProject {
|
||||
|
||||
interface CrossHostProjectScenario {
|
||||
contextWorkspaceId: string;
|
||||
primarySharedWorkspaceId: string;
|
||||
selectedProjectViewKey: string;
|
||||
sharedProjectKey: string;
|
||||
secondaryHost: {
|
||||
@@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
|
||||
await provide({
|
||||
contextWorkspaceId: contextProject.workspaceId,
|
||||
primarySharedWorkspaceId: primarySharedProject.workspaceId,
|
||||
selectedProjectViewKey: projectPlacementViewKey(
|
||||
getServerId(),
|
||||
primarySharedProject.projectId,
|
||||
@@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => {
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("keeps the active workspace project selected when switching hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openProjectDirectoryWithHosts(page, {
|
||||
hosts: [crossHostProject.secondaryHost],
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: crossHostProject.primarySharedWorkspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await addConnectedHostsAndReload(page, [crossHostProject.secondaryHost], {
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const LONG_HOST_NAME =
|
||||
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
|
||||
const LONG_BRANCH_NAME =
|
||||
"feat/app/add-amoled-theme-with-a-very-long-pull-request-and-branch-description";
|
||||
|
||||
function measureControlRightEdges(controls: HTMLElement[]) {
|
||||
const measurements: Array<{ label: string | null; right: number }> = [];
|
||||
for (const control of controls) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
measurements.push({ label: control.getAttribute("aria-label"), right: rect.right });
|
||||
}
|
||||
return measurements;
|
||||
}
|
||||
|
||||
test.describe("New workspace metadata row layout", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-layout-",
|
||||
repo: { branches: [LONG_BRANCH_NAME] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2048, height: 878 });
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: LONG_HOST_NAME,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "srv_e2e_layout_offline",
|
||||
label: "Offline host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, LONG_BRANCH_NAME);
|
||||
|
||||
const composer = page.locator('[data-testid="message-input-root"]:visible');
|
||||
const metadataRow = page.getByTestId("new-workspace-ref-picker-row");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(metadataRow).toBeVisible();
|
||||
|
||||
const [composerBox, controlBoxes] = await Promise.all([
|
||||
composer.boundingBox(),
|
||||
metadataRow.getByRole("button").evaluateAll(measureControlRightEdges),
|
||||
]);
|
||||
expect(composerBox).not.toBeNull();
|
||||
const composerRightRail = composerBox!.x + composerBox!.width;
|
||||
|
||||
for (const control of controlBoxes) {
|
||||
expect(
|
||||
control.right,
|
||||
`${control.label ?? "Metadata control"} crossed the composer rail`,
|
||||
).toBeLessThanOrEqual(composerRightRail + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ const cases: ProviderSubagentCase[] = [
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
expectedName: "Sentinel child",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
providerConfig: { providerOptions: { features: { multi_agent_v2: true } } },
|
||||
prompt:
|
||||
'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openSessions } from "../support/helpers/archive-tab";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
// The constants module is plain TypeScript, so the Playwright runner can import
|
||||
// it — reaching through the `.tsx` picker would drag React Native into Node.
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
const PRIMARY_LABEL = "Primary box";
|
||||
const SECONDARY_LABEL = "Secondary box";
|
||||
|
||||
/** Scopes every assertion to this spec's sessions on a shared daemon. */
|
||||
const NONCE = `hsx${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
async function selectHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("sessions-host-filter-trigger").click();
|
||||
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Searching across hosts is the case a single-daemon spec cannot see: each host
|
||||
// ranks only its own sessions, so the merge is the only thing that can put the
|
||||
// best match first.
|
||||
test.describe("History search across hosts", () => {
|
||||
test.describe.configure({ timeout: 420_000 });
|
||||
|
||||
test("ranks matches from every host together and narrows to one host on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const secondaryDaemon = await startIsolatedHostDaemon(secondaryServerId);
|
||||
const primaryClient = await connectSeedClient();
|
||||
let primaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
|
||||
// The weak match is the newest session on the primary host and the strong
|
||||
// match is the oldest on the secondary, so only a real cross-host merge on
|
||||
// relevance can order them correctly.
|
||||
const weakTitle = `${NONCE} Unbilled usage report`;
|
||||
const strongTitle = `${NONCE} Bill the customer`;
|
||||
const unrelatedTitle = `${NONCE} Terminal resize fix`;
|
||||
|
||||
try {
|
||||
secondaryWorkspace = await seedWorkspace({
|
||||
repoPrefix: "sessions-search-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: strongTitle,
|
||||
});
|
||||
|
||||
primaryWorkspace = await seedWorkspace({ repoPrefix: "sessions-search-primary-" });
|
||||
for (const title of [unrelatedTitle, weakTitle]) {
|
||||
await createIdleAgent(primaryClient, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryDaemon.serverId,
|
||||
label: SECONDARY_LABEL,
|
||||
port: secondaryDaemon.port,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// All hosts: the stronger match leads despite being the older session on
|
||||
// the other daemon.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectRankedTitles(page, [strongTitle, weakTitle]);
|
||||
|
||||
// One host: the other daemon's stronger match drops out entirely.
|
||||
await selectHost(page, getServerId());
|
||||
await expectRankedTitles(page, [weakTitle]);
|
||||
|
||||
await selectHost(page, secondaryDaemon.serverId);
|
||||
await expectRankedTitles(page, [strongTitle]);
|
||||
|
||||
// Back to all hosts, and clearing the query returns every seeded session.
|
||||
await selectHost(page, ALL_HOSTS_OPTION_ID);
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(3, { timeout: 30_000 });
|
||||
} finally {
|
||||
await primaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await secondaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("names a host it could not reach instead of shrinking the results silently", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sessions-search-offline-" });
|
||||
const reachableTitle = `${NONCE} Bill the reachable customer`;
|
||||
|
||||
try {
|
||||
await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: reachableTitle,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: "sessions-search-unreachable",
|
||||
label: SECONDARY_LABEL,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// The unreachable host's sessions were never searched. Reporting only the
|
||||
// matches that were found, with no word about the host that never
|
||||
// answered, is the failure this asserts against.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectRankedTitles(page, [reachableTitle]);
|
||||
} finally {
|
||||
await workspace.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
createIdleAgent,
|
||||
openSessions,
|
||||
resetSeededPageState,
|
||||
} from "../support/helpers/archive-tab";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
|
||||
/**
|
||||
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
|
||||
* can only reach this spec's sessions. The daemon is shared with the rest of
|
||||
* the browser suite and its history is whatever those specs left behind.
|
||||
*/
|
||||
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
const TITLES = {
|
||||
billing: `${NONCE} Add Stripe billing`,
|
||||
unbilled: `${NONCE} Unbilled usage report`,
|
||||
terminal: `${NONCE} Terminal resize fix`,
|
||||
} as const;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
function rowTitles(page: Page) {
|
||||
return page.locator(AGENT_ROW);
|
||||
}
|
||||
|
||||
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = rowTitles(page).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("History search", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let projectId: string;
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("sessions-search-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
}
|
||||
projectId = created.workspace.projectId;
|
||||
const workspaceId = created.workspace.id;
|
||||
|
||||
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
|
||||
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
|
||||
// Ranked results are one flat list — a day heading would claim an order
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A typo has no characters in the text to point at, so the whole word it
|
||||
// resolved to is marked.
|
||||
await search(page, `${NONCE} bulling`);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("finds a session through a typo", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
});
|
||||
|
||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ const DISCORD_DESTINATION =
|
||||
const GITHUB_ISSUE_DESTINATION =
|
||||
/^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/;
|
||||
const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/;
|
||||
const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
// The name and the version are separate cells of a key/value row, so they meet with no space
|
||||
// between them in the row's text content.
|
||||
const APP_VERSION = /^Paseo\s*v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
async function openHelpMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
|
||||
@@ -8,7 +8,6 @@ async function expectBorderHighlight(page: Page, testID: string) {
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
|
||||
@@ -304,7 +304,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openBounds = await openToggle.locator("svg").first().boundingBox();
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
const openBounds = await openIcon.boundingBox();
|
||||
expect(openBounds).not.toBeNull();
|
||||
expect(openBounds?.x).toBeGreaterThan(12);
|
||||
|
||||
@@ -312,7 +314,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
const closedToggle = page.getByTestId("menu-button");
|
||||
const closedBounds = await closedToggle.locator("svg").first().boundingBox();
|
||||
const closedIcon = closedToggle.locator("svg").first();
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
|
||||
@@ -503,12 +503,16 @@ test.describe("Workspace model restart regressions", () => {
|
||||
createdWorkspaceId,
|
||||
]),
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceA]: "running",
|
||||
.toMatchObject({
|
||||
[seeded.workspaceB]: "done",
|
||||
[createdWorkspaceId]: "done",
|
||||
});
|
||||
|
||||
// The restarted provider session may settle while the browser creates the sibling. Its
|
||||
// initial running status is asserted above; this phase verifies that ownership never moves.
|
||||
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
|
||||
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
|
||||
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
@@ -519,11 +523,6 @@ test.describe("Workspace model restart regressions", () => {
|
||||
workspaceId: createdWorkspaceId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "sessions dropdown" rename path. The desktop rename specs drive the right-click
|
||||
* `workspace-tab-context-*` menu, which portals into overlay-root and was never broken. This
|
||||
* exercises the tab switcher's per-session actions on a phone-sized viewport — the path that
|
||||
* regressed when the actions menu presented as a popover instead of a bottom sheet.
|
||||
*/
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const initialTitle = `switcher-rename-${randomUUID().slice(0, 8)}`;
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-switcher-rename-",
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Open this session's "…" actions. Before the fix this tried to open a popover-Modal
|
||||
// that never surfaced over the sheet on native; it now opens as a stacked sheet.
|
||||
const menuBase = `workspace-tab-menu-agent_${session.agentId}`;
|
||||
const actionsTrigger = page.getByTestId(`${menuBase}-trigger`);
|
||||
await expect(actionsTrigger).toBeVisible({ timeout: 15_000 });
|
||||
await actionsTrigger.click();
|
||||
|
||||
const renameItem = page.getByTestId(`${menuBase}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${session.agentId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "Renamed from the dropdown";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,7 @@ export async function launchAgent(input: {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
providerOptions?: { features?: { multi_agent_v2?: boolean } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0-beta.3",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.1",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
@@ -124,6 +125,7 @@
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/babel__standalone": "^7.1.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
@@ -123,6 +124,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { PluginCatalogSync } from "@/plugins/catalog-sync";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
@@ -265,7 +267,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
|
||||
return (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
<PluginCatalogSync serverId={daemon.serverId} client={client} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -643,7 +645,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// six registered theme keys, so the active key is always current.
|
||||
// registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
@@ -668,7 +670,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
);
|
||||
}
|
||||
@@ -891,6 +893,7 @@ function RootStack() {
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="runtime-component" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
@@ -954,19 +957,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
</SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -998,9 +999,11 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PluginSurfaceScreen } from "@/plugins/surface-screen";
|
||||
|
||||
export default function PluginSurfaceRoute() {
|
||||
return <PluginSurfaceScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RuntimeComponentPrototypeScreen } from "@/runtime-component/prototype-screen";
|
||||
|
||||
export default function RuntimeComponentRoute() {
|
||||
return <RuntimeComponentPrototypeScreen />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
|
||||
|
||||
describe("agent attachment pill content", () => {
|
||||
it("presents external resources with their provider identity", () => {
|
||||
const content = getAgentAttachmentPillContent(
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
},
|
||||
},
|
||||
i18n.t,
|
||||
);
|
||||
|
||||
expect(content.title).toBe("Plugin attachments");
|
||||
expect(content.subtitle).toBe("Linear issue ENG-123");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
if (attachment.externalResource) {
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.externalResource.title,
|
||||
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ForgeSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PluginResourceComposerAttachment } from "@/plugins/attachments/model";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| WorkspaceFileComposerAttachment
|
||||
| PluginResourceComposerAttachment
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
|
||||
@@ -6,7 +6,10 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
resolveShortcutKeysForAction,
|
||||
type ShortcutOverrides,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
@@ -36,9 +39,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
||||
return StaticIcon;
|
||||
}
|
||||
|
||||
function resolveWorkspaceShortcuts(
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): WorkspaceCommandCenterShortcuts {
|
||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
return {
|
||||
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
|
||||
|
||||
@@ -21,6 +21,9 @@ import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -32,6 +35,18 @@ interface AgentListProps {
|
||||
listFooterComponent?: ReactElement | null;
|
||||
showAttentionIndicator?: boolean;
|
||||
showHostColumn?: boolean;
|
||||
/**
|
||||
* Where a search matched each row, keyed by `serverId:agentId`. Rows mark the
|
||||
* spans so the list can explain why a result is in it — the subsequence and
|
||||
* typo tiers match characters the eye would not find on its own.
|
||||
*/
|
||||
searchMatchesByAgentKey?: Record<string, AgentSearchMatch[]>;
|
||||
/**
|
||||
* Renders one flat list in the given order instead of grouping by day. Day
|
||||
* headings claim the list is chronological, which is a lie once the caller
|
||||
* has ordered it by something else — relevance, for instance.
|
||||
*/
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
|
||||
@@ -127,12 +142,14 @@ function SessionBadge({
|
||||
function WorkspaceTitlePrefix({
|
||||
visible,
|
||||
workspaceName,
|
||||
ranges,
|
||||
testID,
|
||||
iconSize,
|
||||
color,
|
||||
}: {
|
||||
visible: boolean;
|
||||
workspaceName: string;
|
||||
ranges?: readonly MatchRange[];
|
||||
testID: string;
|
||||
iconSize: number;
|
||||
color: string;
|
||||
@@ -143,9 +160,13 @@ function WorkspaceTitlePrefix({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
<HighlightedText
|
||||
text={workspaceName}
|
||||
ranges={ranges}
|
||||
style={styles.workspaceTitleText}
|
||||
numberOfLines={1}
|
||||
testID={testID}
|
||||
/>
|
||||
<ChevronRight size={iconSize} color={color} />
|
||||
</>
|
||||
);
|
||||
@@ -203,6 +224,7 @@ function SessionRowTrailingAttention({
|
||||
|
||||
function SessionRow({
|
||||
agent,
|
||||
searchMatches,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
@@ -211,6 +233,7 @@ function SessionRow({
|
||||
onLongPress,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
searchMatches?: readonly AgentSearchMatch[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
showAttentionIndicator: boolean;
|
||||
@@ -228,6 +251,11 @@ function SessionRow({
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const rangesFor = useCallback(
|
||||
(field: AgentSearchMatch["field"]) =>
|
||||
searchMatches?.find((match) => match.field === field)?.ranges,
|
||||
[searchMatches],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -266,6 +294,7 @@ function SessionRow({
|
||||
<WorkspaceTitlePrefix
|
||||
visible={!isMobile && Boolean(workspaceName)}
|
||||
workspaceName={workspaceName}
|
||||
ranges={rangesFor("workspace")}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
iconSize={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
@@ -273,9 +302,12 @@ function SessionRow({
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={sessionTitleStyle} numberOfLines={1}>
|
||||
{agent.title || t("agentList.fallbackTitle")}
|
||||
</Text>
|
||||
<HighlightedText
|
||||
text={agent.title || t("agentList.fallbackTitle")}
|
||||
ranges={agent.title ? rangesFor("title") : undefined}
|
||||
style={sessionTitleStyle}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SessionRowBadges
|
||||
agent={agent}
|
||||
archivedIcon={archivedIcon}
|
||||
@@ -285,29 +317,29 @@ function SessionRow({
|
||||
</View>
|
||||
{isMobile ? (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={projectName}
|
||||
ranges={rangesFor("project")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={branch}
|
||||
ranges={rangesFor("branch")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={workspaceName}
|
||||
ranges={rangesFor("workspace")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
@@ -323,25 +355,25 @@ function SessionRow({
|
||||
</View>
|
||||
{!isMobile ? (
|
||||
<View style={styles.rowColumns}>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={projectName}
|
||||
ranges={rangesFor("project")}
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
/>
|
||||
{showHostColumn ? (
|
||||
<Text style={styles.columnMetaHost} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={branch}
|
||||
ranges={rangesFor("branch")}
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
@@ -365,6 +397,8 @@ export function AgentList({
|
||||
listFooterComponent,
|
||||
showAttentionIndicator = true,
|
||||
showHostColumn = false,
|
||||
searchMatchesByAgentKey,
|
||||
flat = false,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -432,6 +466,14 @@ export function AgentList({
|
||||
}, [actionAgent, actionClient, archiveAgent]);
|
||||
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
if (flat) {
|
||||
return agents.map((agent) => ({
|
||||
type: "agent" as const,
|
||||
key: `${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
}));
|
||||
}
|
||||
|
||||
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
|
||||
for (const agent of agents) {
|
||||
const section = deriveDateSectionKey(agent.lastActivityAt);
|
||||
@@ -452,7 +494,7 @@ export function AgentList({
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [agents]);
|
||||
}, [agents, flat]);
|
||||
|
||||
const renderItem: ListRenderItem<FlatListItem> = useCallback(
|
||||
({ item }) => {
|
||||
@@ -466,6 +508,7 @@ export function AgentList({
|
||||
return (
|
||||
<SessionRow
|
||||
agent={item.agent}
|
||||
searchMatches={searchMatchesByAgentKey?.[item.key]}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
@@ -479,6 +522,7 @@ export function AgentList({
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
isMobile,
|
||||
searchMatchesByAgentKey,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
showHostColumn,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
@@ -36,6 +37,10 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
@@ -167,6 +172,9 @@ export function ExplorerSidebar({
|
||||
});
|
||||
const startWidthRef = useRef(visibleExplorerWidth);
|
||||
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
@@ -185,8 +193,15 @@ export function ExplorerSidebar({
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = visibleExplorerWidth;
|
||||
.onBegin(() => {
|
||||
scheduleOnRN(showResizeGrip);
|
||||
})
|
||||
// See the left sidebar's gesture: horizontal intent only, with the start
|
||||
// width anchored to the activation translation so the threshold is free.
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleExplorerWidth + event.translationX;
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -198,8 +213,18 @@ export function ExplorerSidebar({
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth],
|
||||
[
|
||||
hideResizeGrip,
|
||||
resizeWidth,
|
||||
setExplorerWidth,
|
||||
showResizeGrip,
|
||||
viewportWidth,
|
||||
visibleExplorerWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -220,6 +245,7 @@ export function ExplorerSidebar({
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="explorer-sidebar-resize-handle"
|
||||
/>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface HostFilterProps {
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
triggerTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export function HostFilter({
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
triggerTestID,
|
||||
hostOptionTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
@@ -64,6 +66,7 @@ export function HostFilter({
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
hostOptionTestID={hostOptionTestID}
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
|
||||
@@ -266,6 +266,7 @@ export function HostPicker({
|
||||
active={active}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
testID={hostOptionTestID?.(option.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
buildEffectiveBindings,
|
||||
buildKeyboardShortcutHelpSections,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
|
||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
||||
|
||||
@@ -42,9 +46,13 @@ export function KeyboardShortcutsDialog() {
|
||||
const shortcutOs = getShortcutOs();
|
||||
const isMac = shortcutOs === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
// Effective bindings, so a shortcut the user unassigned lists no keys here
|
||||
// instead of advertising a default that no longer fires.
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||
[isDesktopApp, isMac],
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
|
||||
[bindings, isDesktopApp, isMac],
|
||||
);
|
||||
const visibleSections = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
|
||||
@@ -22,10 +22,15 @@ import {
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||
@@ -69,6 +74,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { PluginSidebarItems } from "@/plugins/sidebar-items";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -673,6 +679,7 @@ function MobileSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
@@ -771,6 +778,9 @@ function DesktopSidebar({
|
||||
|
||||
const startWidthRef = useRef(visibleSidebarWidth);
|
||||
const resizeWidth = useSharedValue(visibleSidebarWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
@@ -780,8 +790,16 @@ function DesktopSidebar({
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = visibleSidebarWidth;
|
||||
.onBegin(() => {
|
||||
scheduleOnRN(showResizeGrip);
|
||||
})
|
||||
// Horizontal intent only, so a finger dragging down the touch grip scrolls
|
||||
// the workspace list instead of resizing. Anchoring the start width to the
|
||||
// activation translation keeps the extra threshold from jumping the edge.
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleSidebarWidth - event.translationX;
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -794,8 +812,18 @@ function DesktopSidebar({
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setSidebarWidth)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth],
|
||||
[
|
||||
hideResizeGrip,
|
||||
resizeWidth,
|
||||
setSidebarWidth,
|
||||
showResizeGrip,
|
||||
viewportWidth,
|
||||
visibleSidebarWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -857,6 +885,7 @@ function DesktopSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -894,6 +923,7 @@ function DesktopSidebar({
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -22,7 +22,6 @@ import Markdown, {
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
@@ -110,11 +109,7 @@ export function MarkdownRenderer({
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
return <MarkdownPartList parts={parts} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RootErrorFallback } from "./root-error-boundary";
|
||||
|
||||
const { runtime, theme } = vi.hoisted(() => ({
|
||||
runtime: { breakpoint: "xs" },
|
||||
theme: {
|
||||
spacing: { 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6, lg: 8, xl: 12 },
|
||||
fontFamily: { ui: "system-ui", mono: "monospace" },
|
||||
fontSize: { xs: 11, code: 12, sm: 13, base: 15, xl: 20 },
|
||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||
colors: {
|
||||
accent: "#369",
|
||||
accentForeground: "#fff",
|
||||
border: "#333",
|
||||
borderAccent: "#444",
|
||||
destructive: "#c33",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface0: "#111",
|
||||
surface1: "#181818",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
},
|
||||
opacity: { 50: 0.5 },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme, rt: runtime }),
|
||||
withUnistyles: (component: unknown) => component,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 47, right: 3, bottom: 34, left: 5 }),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("RootErrorFallback", () => {
|
||||
for (const breakpoint of ["xs", "lg"]) {
|
||||
it(`keeps the safe-area header and retry action outside the scrollable details on ${breakpoint}`, () => {
|
||||
runtime.breakpoint = breakpoint;
|
||||
act(() => {
|
||||
root?.render(<RootErrorFallback error={"failure\n".repeat(200)} onRetry={vi.fn()} />);
|
||||
});
|
||||
|
||||
const screen = document.querySelector('[data-testid="root-error-boundary"]');
|
||||
const header = document.querySelector('[data-testid="root-error-boundary-header"]');
|
||||
const details = document.querySelector('[data-testid="root-error-boundary-details"]');
|
||||
const retry = document.querySelector('[data-testid="root-error-boundary-retry"]');
|
||||
|
||||
expect(screen).not.toBeNull();
|
||||
expect(header).not.toBeNull();
|
||||
expect(details).not.toBeNull();
|
||||
expect(retry).not.toBeNull();
|
||||
expect(screen?.getAttribute("style")).toContain("padding: 47px 3px 34px 5px");
|
||||
expect(details?.contains(header)).toBe(false);
|
||||
expect(details?.contains(retry)).toBe(false);
|
||||
expect(screen?.contains(retry)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatCaughtValue } from "./root-error-details";
|
||||
|
||||
interface RootErrorBoundaryProps {
|
||||
@@ -48,110 +51,113 @@ export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErr
|
||||
}
|
||||
}
|
||||
|
||||
// A stack trace is reference material, not the screen. Cap it so the message and
|
||||
// the retry action stay the shape of the page; the rest scrolls.
|
||||
const DETAILS_MAX_HEIGHT = 300;
|
||||
|
||||
interface RootErrorFallbackProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||
export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const retry = (
|
||||
<Button variant="default" onPress={onRetry} testID="root-error-boundary-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
]}
|
||||
testID="root-error-boundary"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
|
||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
||||
<Text style={styles.body}>{t("rootError.body")}</Text>
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.messageLabel}>{t("rootError.details")}</Text>
|
||||
<Text style={styles.message}>{error}</Text>
|
||||
<View style={styles.header} testID="root-error-boundary-header">
|
||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
||||
<Text style={styles.body}>{t("rootError.body")}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onRetry}
|
||||
style={retryButtonStyle}
|
||||
testID="root-error-boundary-retry"
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.detailsLabel}>{t("rootError.details")}</Text>
|
||||
<ScrollableCodeSurface
|
||||
maxHeight={isCompact ? undefined : DETAILS_MAX_HEIGHT}
|
||||
style={styles.detailsSurface}
|
||||
scrollStyle={styles.detailsScroll}
|
||||
testID="root-error-boundary-details"
|
||||
>
|
||||
{error}
|
||||
</ScrollableCodeSurface>
|
||||
</View>
|
||||
{isCompact ? null : retry}
|
||||
</View>
|
||||
</ScrollView>
|
||||
{isCompact ? <View style={styles.footer}>{retry}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
|
||||
return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
content: {
|
||||
flex: 1,
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
content: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
kicker: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
header: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textAlign: "center",
|
||||
},
|
||||
body: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageBox: {
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
messageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
message: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
retryButton: {
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 40,
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
details: {
|
||||
flexShrink: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
retryButtonPressed: {
|
||||
opacity: 0.85,
|
||||
detailsLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: theme.colors.accentForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
detailsSurface: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
detailsScroll: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
footer: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSidebarResizeHandleGeometry } from "./sidebar-resize-handle-layout";
|
||||
|
||||
const MIN_TOUCH_TARGET = 24;
|
||||
|
||||
describe("resolveSidebarResizeHandleGeometry", () => {
|
||||
it("keeps the touch hit area inside the sidebar so native hit-testing reaches it", () => {
|
||||
const geometry = resolveSidebarResizeHandleGeometry(false);
|
||||
|
||||
expect(geometry.edgeOffset).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
|
||||
expect(geometry.height ?? 0).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
|
||||
});
|
||||
|
||||
it("straddles the border for a fine pointer", () => {
|
||||
const geometry = resolveSidebarResizeHandleGeometry(true);
|
||||
|
||||
expect(geometry.edgeOffset).toBe(-geometry.width / 2);
|
||||
expect(geometry.height).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Geometry for the sidebar resize handle.
|
||||
*
|
||||
* A mouse can grab a hairline, so the pointer variant straddles the sidebar
|
||||
* border — half the strip hangs outside the sidebar's own bounds, which is
|
||||
* fine because web hit-testing does not clip to the parent box.
|
||||
*
|
||||
* Native hit-testing does clip: iOS `hitTest:` returns nil for points outside a
|
||||
* view's bounds and Gesture Handler's Android traversal only recurses into
|
||||
* children whose ancestors contain the point. A strip hanging outside the
|
||||
* sidebar is dead on touch, and what remains is far too small for a finger.
|
||||
* So the touch variant sits fully inside the sidebar and is sized for a thumb.
|
||||
*/
|
||||
|
||||
export const POINTER_HANDLE_WIDTH = 10;
|
||||
export const TOUCH_HANDLE_WIDTH = 24;
|
||||
export const TOUCH_HANDLE_HEIGHT = 88;
|
||||
|
||||
/**
|
||||
* A finger resting on the grip drifts before it commits. Requiring horizontal
|
||||
* intent before the pan activates, and failing it once the drift is mostly
|
||||
* vertical, leaves the sidebar's own scrolling reachable through the grip.
|
||||
* A mouse press moves less than either threshold, so pointer drags are
|
||||
* unaffected.
|
||||
*/
|
||||
export const SIDEBAR_RESIZE_ACTIVATION_OFFSET = 6;
|
||||
export const SIDEBAR_RESIZE_FAIL_OFFSET = 12;
|
||||
|
||||
export type SidebarResizeEdge = "left" | "right";
|
||||
|
||||
export interface SidebarResizeHandleGeometry {
|
||||
/**
|
||||
* Distance from the sidebar edge to the near side of the hit area. Negative
|
||||
* means the hit area overhangs the sidebar, which only touch forbids.
|
||||
*/
|
||||
edgeOffset: number;
|
||||
width: number;
|
||||
/** `null` stretches the hit area to the sidebar's full height. */
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export function resolveSidebarResizeHandleGeometry(
|
||||
finePointer: boolean,
|
||||
): SidebarResizeHandleGeometry {
|
||||
if (finePointer) {
|
||||
return {
|
||||
edgeOffset: -POINTER_HANDLE_WIDTH / 2,
|
||||
width: POINTER_HANDLE_WIDTH,
|
||||
height: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
edgeOffset: 0,
|
||||
width: TOUCH_HANDLE_WIDTH,
|
||||
height: TOUCH_HANDLE_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,17 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
resolveSidebarResizeHandleGeometry,
|
||||
type SidebarResizeEdge,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
|
||||
interface SidebarResizeHandleProps {
|
||||
edge: "left" | "right";
|
||||
edge: SidebarResizeEdge;
|
||||
gesture: GestureType;
|
||||
pressed: boolean;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
@@ -18,13 +24,29 @@ const webResizeCursorStyle = isWeb
|
||||
} as object)
|
||||
: null;
|
||||
|
||||
export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
|
||||
function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) {
|
||||
return edge === "left" ? { left: edgeOffset } : { right: edgeOffset };
|
||||
}
|
||||
|
||||
export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
|
||||
const finePointer = useHasFinePointer();
|
||||
|
||||
if (finePointer) {
|
||||
return <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
|
||||
}
|
||||
return <TouchResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
|
||||
}
|
||||
|
||||
function PointerResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
|
||||
const [highlighted, setHighlighted] = useState(false);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hitAreaStyle =
|
||||
edge === "left"
|
||||
? [styles.hitArea, styles.leftEdge, webResizeCursorStyle]
|
||||
: [styles.hitArea, styles.rightEdge, webResizeCursorStyle];
|
||||
const geometry = resolveSidebarResizeHandleGeometry(true);
|
||||
const hitAreaStyle = [
|
||||
styles.hitArea,
|
||||
{ width: geometry.width },
|
||||
edgeOffsetStyle(edge, geometry.edgeOffset),
|
||||
webResizeCursorStyle,
|
||||
];
|
||||
|
||||
const cancelHighlightTimer = useCallback(() => {
|
||||
if (highlightTimerRef.current === null) return;
|
||||
@@ -63,20 +85,49 @@ export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHand
|
||||
);
|
||||
}
|
||||
|
||||
function TouchResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
|
||||
const geometry = resolveSidebarResizeHandleGeometry(false);
|
||||
// `box-none` keeps the full-height column out of hit-testing so only the
|
||||
// grab target steals taps from the rows behind it.
|
||||
const layerStyle = [
|
||||
styles.touchLayer,
|
||||
{ width: geometry.width },
|
||||
edgeOffsetStyle(edge, geometry.edgeOffset),
|
||||
];
|
||||
const targetStyle = [
|
||||
styles.touchTarget,
|
||||
{ width: geometry.width, height: geometry.height ?? undefined },
|
||||
];
|
||||
const gripStyle = [
|
||||
styles.grip,
|
||||
edge === "left" ? styles.leftEdgeGrip : styles.rightEdgeGrip,
|
||||
pressed ? styles.visibleGrip : styles.hiddenGrip,
|
||||
];
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={layerStyle}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View
|
||||
testID={testID}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
collapsable={false}
|
||||
style={targetStyle}
|
||||
>
|
||||
<View pointerEvents="none" testID={`${testID}-grip`} style={gripStyle} />
|
||||
</View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
hitArea: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
leftEdge: {
|
||||
left: -5,
|
||||
},
|
||||
rightEdge: {
|
||||
right: -5,
|
||||
},
|
||||
highlight: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -86,4 +137,36 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.foreground,
|
||||
opacity: 0.25,
|
||||
},
|
||||
touchLayer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
touchTarget: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
grip: {
|
||||
width: 4,
|
||||
height: 36,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
hiddenGrip: {
|
||||
opacity: 0,
|
||||
},
|
||||
visibleGrip: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
leftEdgeGrip: {
|
||||
alignSelf: "flex-start",
|
||||
marginLeft: theme.spacing[0.5],
|
||||
},
|
||||
rightEdgeGrip: {
|
||||
alignSelf: "flex-end",
|
||||
marginRight: theme.spacing[0.5],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2415,11 +2415,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceListContainer: {},
|
||||
// Kept in step with `workspaceRow` above. It stands in a project's list where a workspace row
|
||||
// would be, so it takes that row's geometry and both of its fills.
|
||||
//
|
||||
// The one departure is the extra left padding: it only ever renders under its project header, so
|
||||
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
|
||||
// pressed fills stay the same box as every other row in the sidebar.
|
||||
newWorkspaceGhostRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -68,9 +68,10 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
return (
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID={`sidebar-help-host-version-${host.serverId}`}
|
||||
>
|
||||
{host.label} {version}
|
||||
{host.label}
|
||||
</DropdownMenuHint>
|
||||
);
|
||||
}
|
||||
@@ -167,8 +168,12 @@ export function SidebarHelpMenu() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint style={styles.versionHint} testID="sidebar-help-version">
|
||||
{t("sidebar.help.version", { version })}
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID="sidebar-help-version"
|
||||
>
|
||||
{t("sidebar.help.appName")}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
@@ -741,15 +740,7 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ToolCallDetailsContentInner {...props} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallDetailsContentInner({
|
||||
export function ToolCallDetailsContent({
|
||||
detail,
|
||||
errorText,
|
||||
maxHeight,
|
||||
|
||||
@@ -31,10 +31,11 @@ export function Alert({
|
||||
}: AlertProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accentColor = resolveAccentColor(variant, theme);
|
||||
const borderColor = variant === "success" ? theme.colors.border : accentColor;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
|
||||
[accentColor],
|
||||
() => [styles.container, borderColor ? { borderColor } : null],
|
||||
[borderColor],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(
|
||||
@@ -73,7 +74,7 @@ function resolveAccentColor(
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): string | null {
|
||||
if (variant === "info") return theme.colors.palette.blue[300];
|
||||
if (variant === "success") return theme.colors.palette.green[400];
|
||||
if (variant === "success") return theme.colors.statusSuccess;
|
||||
if (variant === "warning") return theme.colors.palette.amber[500];
|
||||
if (variant === "error") return theme.colors.destructive;
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
|
||||
import {
|
||||
compareMatchScores,
|
||||
type MatchScore,
|
||||
scoreTextFields,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
export type ComboboxOptionKind = "directory" | "file";
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toHighlightSegments } from "./highlighted-text-segments";
|
||||
|
||||
/** What the eye sees, written back out with the marked runs bracketed. */
|
||||
function marked(text: string, ranges: { start: number; length: number }[]): string {
|
||||
return toHighlightSegments(text, ranges)
|
||||
.map((segment) => (segment.marked ? `[${segment.text}]` : segment.text))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("toHighlightSegments", () => {
|
||||
it("marks one span and keeps the rest intact", () => {
|
||||
expect(marked("add stripe billing", [{ start: 4, length: 6 }])).toBe("add [stripe] billing");
|
||||
});
|
||||
|
||||
it("marks several spans in order", () => {
|
||||
expect(
|
||||
marked("add stripe billing", [
|
||||
{ start: 4, length: 6 },
|
||||
{ start: 11, length: 4 },
|
||||
]),
|
||||
).toBe("add [stripe] [bill]ing");
|
||||
});
|
||||
|
||||
it("marks a span at the very start and at the very end", () => {
|
||||
expect(marked("main", [{ start: 0, length: 4 }])).toBe("[main]");
|
||||
expect(marked("feat/main", [{ start: 5, length: 4 }])).toBe("feat/[main]");
|
||||
});
|
||||
|
||||
it("clamps a range that runs past the end of the text", () => {
|
||||
expect(marked("main", [{ start: 2, length: 99 }])).toBe("ma[in]");
|
||||
});
|
||||
|
||||
it("drops a range that starts past the end of the text", () => {
|
||||
expect(marked("main", [{ start: 99, length: 4 }])).toBe("main");
|
||||
});
|
||||
|
||||
it("does not let overlapping ranges duplicate characters", () => {
|
||||
expect(
|
||||
marked("billing", [
|
||||
{ start: 0, length: 4 },
|
||||
{ start: 2, length: 5 },
|
||||
]),
|
||||
).toBe("[bill][ing]");
|
||||
});
|
||||
|
||||
it("returns the whole text as one unmarked run when there are no ranges", () => {
|
||||
expect(toHighlightSegments("main", [])).toEqual([{ start: 0, text: "main", marked: false }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
export interface HighlightSegment {
|
||||
/** Offset into the source text; unique per segment, so it doubles as a key. */
|
||||
start: number;
|
||||
text: string;
|
||||
marked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into marked and unmarked runs. Ranges are clamped and skipped
|
||||
* rather than trusted: they arrive over the wire, and a stale one must not slice
|
||||
* a title into gibberish.
|
||||
*/
|
||||
export function toHighlightSegments(
|
||||
text: string,
|
||||
ranges: readonly MatchRange[],
|
||||
): HighlightSegment[] {
|
||||
const segments: HighlightSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(range.start, cursor);
|
||||
const end = Math.min(start + range.length, text.length);
|
||||
if (end <= start) continue;
|
||||
if (start > cursor) {
|
||||
segments.push({ start: cursor, text: text.slice(cursor, start), marked: false });
|
||||
}
|
||||
segments.push({ start, text: text.slice(start, end), marked: true });
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
segments.push({ start: cursor, text: text.slice(cursor), marked: false });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
import { toHighlightSegments } from "./highlighted-text-segments";
|
||||
|
||||
export interface HighlightedTextProps {
|
||||
text: string;
|
||||
/** Character spans to mark, in order and non-overlapping. */
|
||||
ranges?: readonly MatchRange[];
|
||||
style?: StyleProp<TextStyle>;
|
||||
numberOfLines?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text with some of its characters marked. Used to show why a search result is
|
||||
* in the list — the subsequence and typo tiers can match on characters the eye
|
||||
* would never find unaided.
|
||||
*
|
||||
* The mark is a surface tint rather than the accent: it appears many times on a
|
||||
* screen, so it has to stay quiet and survive every theme, and the one accent
|
||||
* per surface is spoken for.
|
||||
*/
|
||||
export function HighlightedText({
|
||||
text,
|
||||
ranges,
|
||||
style,
|
||||
numberOfLines,
|
||||
testID,
|
||||
}: HighlightedTextProps): ReactElement {
|
||||
const segments = useMemo(
|
||||
() => (ranges && ranges.length > 0 ? toHighlightSegments(text, ranges) : null),
|
||||
[ranges, text],
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return (
|
||||
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
|
||||
{segments.map((segment) =>
|
||||
segment.marked ? (
|
||||
<Text key={segment.start} style={styles.mark}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={segment.start}>{segment.text}</Text>
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
mark: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -103,13 +103,25 @@ export function MenuSeparator({
|
||||
|
||||
export function MenuHint({
|
||||
children,
|
||||
trailing,
|
||||
style,
|
||||
testID,
|
||||
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||
}: PropsWithChildren<{
|
||||
trailing?: ReactNode;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
testID?: string;
|
||||
}>): ReactElement {
|
||||
const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]);
|
||||
return (
|
||||
<View style={hintContainerStyle} testID={testID}>
|
||||
<Text style={styles.hintText}>{children}</Text>
|
||||
<Text style={styles.hintText} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
{trailing === undefined ? null : (
|
||||
<Text style={styles.hintText} numberOfLines={1}>
|
||||
{trailing}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -310,13 +322,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginVertical: theme.spacing[1],
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
},
|
||||
// A hint with `trailing` is a key on the left edge and its value on the right, so the values
|
||||
// line up down the menu's right rail instead of ragging with the length of each key.
|
||||
hintContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hintText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeDisplayChord } from "./normalize-display-chord";
|
||||
|
||||
describe("normalizeDisplayChord", () => {
|
||||
it("returns null when neither keys nor chord is given", () => {
|
||||
expect(normalizeDisplayChord()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty keys array", () => {
|
||||
// The trap: [] wraps to [[]], whose first element is truthy.
|
||||
expect(normalizeDisplayChord(undefined, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty chord", () => {
|
||||
expect(normalizeDisplayChord([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a chord holding an empty combo", () => {
|
||||
expect(normalizeDisplayChord([[]])).toBeNull();
|
||||
expect(normalizeDisplayChord([["mod", "K"], []])).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps keys into a single-combo chord", () => {
|
||||
expect(normalizeDisplayChord(undefined, ["mod", "K"])).toEqual([["mod", "K"]]);
|
||||
});
|
||||
|
||||
it("passes a multi-step chord through unchanged", () => {
|
||||
expect(normalizeDisplayChord([["mod", "K"], ["S"]])).toEqual([["mod", "K"], ["S"]]);
|
||||
});
|
||||
|
||||
it("prefers chord over keys when both are given", () => {
|
||||
expect(normalizeDisplayChord([["alt", "W"]], ["mod", "K"])).toEqual([["alt", "W"]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
/**
|
||||
* The combos a `Shortcut` should render, or `null` when it should render
|
||||
* nothing at all.
|
||||
*
|
||||
* `keys={[]}` is the trap this exists to close: it wraps to `[[]]`, whose first
|
||||
* element is an empty array — truthy — so a first-element check waves it
|
||||
* through and an empty badge pill renders. A shortcut with no keys, whether the
|
||||
* user unassigned it or it ships without a default combo, has to produce no
|
||||
* element at all.
|
||||
*/
|
||||
export function normalizeDisplayChord(
|
||||
chord?: ShortcutKey[][],
|
||||
keys?: ShortcutKey[],
|
||||
): ShortcutKey[][] | null {
|
||||
const combos = chord ?? (keys ? [keys] : []);
|
||||
if (combos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// A chord with an empty step cannot be drawn honestly: rendering it would
|
||||
// show a blank pill, and dropping the step would advertise a shorter
|
||||
// sequence than the one that actually fires.
|
||||
if (combos.some((combo) => combo.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
return combos;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -56,7 +55,7 @@ export function SurfaceCard({
|
||||
[bordered, style, surfaceStyle],
|
||||
);
|
||||
|
||||
const surface = (
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
@@ -67,7 +66,6 @@ export function SurfaceCard({
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <AppearanceStyleBoundary>{surface}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
export function ScrollableCodeSurface({
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useState, type ReactElement } from "react";
|
||||
import { Pressable, TextInput, View } from "react-native";
|
||||
import { Search, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedSearch = withUnistyles(Search);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme: Theme) => ({
|
||||
// Placeholders sit at foregroundMuted and no dimmer — see docs/design.md §14.
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
selectionColor: theme.colors.foreground,
|
||||
}));
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export interface SearchFieldProps {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder: string;
|
||||
/** Falls back to `placeholder`, which already names the field. */
|
||||
accessibilityLabel?: string;
|
||||
clearAccessibilityLabel: string;
|
||||
testID?: string;
|
||||
clearTestID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standalone search box that leads a screen's filter rail. Its chrome is
|
||||
* the host filter pill's, so the two sit on the same rail and read as one row
|
||||
* of controls; only the border colour moves on focus, which keeps the leading
|
||||
* icon from shifting. It owns its own clear affordance — a caller rendering its
|
||||
* own X has been handed the wrong component.
|
||||
*/
|
||||
export function SearchField({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
accessibilityLabel,
|
||||
clearAccessibilityLabel,
|
||||
testID,
|
||||
clearTestID,
|
||||
}: SearchFieldProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
const handleClear = useCallback(() => onChangeText(""), [onChangeText]);
|
||||
|
||||
return (
|
||||
<View style={[styles.field, isFocused && styles.fieldFocused]}>
|
||||
<ThemedSearch size={14} uniProps={mutedColorMapping} />
|
||||
<ThemedTextInput
|
||||
testID={testID}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
accessibilityLabel={accessibilityLabel ?? placeholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
style={styles.input}
|
||||
/>
|
||||
{value.length > 0 ? (
|
||||
<Pressable
|
||||
testID={clearTestID}
|
||||
onPress={handleClear}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={clearAccessibilityLabel}
|
||||
hitSlop={8}
|
||||
>
|
||||
<ThemedX size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_FIELD_MAX_WIDTH = 420;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
field: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: SEARCH_FIELD_MAX_WIDTH,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
fieldFocused: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
height: 20,
|
||||
// The browser's focus ring would sit inside the field's own focus border.
|
||||
// `outlineWidth` is typed on ViewStyle since RN 0.81 and is a no-op on
|
||||
// native, so this needs neither a cast nor a platform branch.
|
||||
outlineWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
@@ -17,9 +18,8 @@ export function Shortcut({
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const displayChord = chord ?? (keys ? [keys] : []);
|
||||
const displayChord = normalizeDisplayChord(chord, keys);
|
||||
const shortcutOs = getShortcutOs();
|
||||
const singleCombo = displayChord[0];
|
||||
|
||||
const badgeStyle = useMemo(() => [styles.badge, style], [style]);
|
||||
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
|
||||
@@ -29,8 +29,11 @@ export function Shortcut({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!singleCombo) {
|
||||
return <View style={style} />;
|
||||
const singleCombo = displayChord?.[0];
|
||||
// Render nothing, literally — an empty <View> would still consume the
|
||||
// parent's `gap` and leave a phantom slot where the badge used to be.
|
||||
if (!displayChord || !singleCombo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (displayChord.length === 1) {
|
||||
|
||||
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
|
||||
it("opens plugin resource URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: {
|
||||
kind: "plugin_resource",
|
||||
pluginId: "linear",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Linear issue",
|
||||
sourceIcon: "CircleDot",
|
||||
item: {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
resourceType: "issue",
|
||||
},
|
||||
},
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments/model";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.kind === "plugin_resource") {
|
||||
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||
images.push(attachment.attachment.screenshot);
|
||||
|
||||
@@ -125,6 +125,8 @@ import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { readClipboardImage } from "./clipboard-image";
|
||||
import { usePluginAttachmentPicker } from "@/plugins/attachments/picker";
|
||||
import { PluginResourceAttachmentPill } from "@/plugins/attachments/pill";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
@@ -440,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
onRemove,
|
||||
});
|
||||
}
|
||||
if (attachment.kind === "plugin_resource") {
|
||||
return (
|
||||
<PluginResourceAttachmentPill
|
||||
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openGithub}
|
||||
removeLabel={labels.removeGithub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GithubAttachmentPill
|
||||
key={`${attachment.item.kind}:${attachment.item.number}`}
|
||||
@@ -1158,6 +1174,14 @@ export function Composer({
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
||||
const attachButtonRef = useRef<View | null>(null);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
const pluginAttachments = usePluginAttachmentPicker({
|
||||
serverId,
|
||||
client,
|
||||
connected: isConnected,
|
||||
attachments,
|
||||
onChangeAttachments: setSelectedAttachments,
|
||||
anchorRef: attachButtonRef,
|
||||
});
|
||||
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
|
||||
const keyboardHandlerIdRef = useRef(
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
@@ -1922,6 +1946,7 @@ export function Composer({
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
...pluginAttachments.menuItems,
|
||||
{
|
||||
id: "file",
|
||||
label: t("composer.attachments.addFile"),
|
||||
@@ -1932,7 +1957,14 @@ export function Composer({
|
||||
},
|
||||
);
|
||||
return items;
|
||||
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
|
||||
}, [
|
||||
forgePresentation,
|
||||
handlePasteImage,
|
||||
handlePickFile,
|
||||
handlePickImage,
|
||||
pluginAttachments.menuItems,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: ForgeSearchItem) => {
|
||||
@@ -2189,6 +2221,7 @@ export function Composer({
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{pluginAttachments.picker}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
useBrowserStore,
|
||||
} from "@/desktop/browser/store";
|
||||
import {
|
||||
applyBrowserWebviewViewport,
|
||||
applyInactiveBrowserWebviewViewport,
|
||||
isResidentBrowserWebviewReady,
|
||||
prepareBrowserWebview,
|
||||
@@ -787,8 +786,7 @@ export function BrowserPane({
|
||||
}
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
if (isPresentedRef.current) {
|
||||
applyBrowserWebviewViewport(webview, browserViewportRef.current);
|
||||
presentBrowserWebview(browserId, webview, host, clip);
|
||||
presentBrowserWebview(browserId, webview, host, clip, browserViewportRef.current);
|
||||
} else {
|
||||
applyInactiveBrowserWebviewViewport(browserId, webview, browserViewportRef.current);
|
||||
}
|
||||
@@ -799,7 +797,13 @@ export function BrowserPane({
|
||||
if (!isPresentedRef.current) {
|
||||
return;
|
||||
}
|
||||
presentBrowserWebview(browserIdRef.current, webview, host, clip);
|
||||
presentBrowserWebview(
|
||||
browserIdRef.current,
|
||||
webview,
|
||||
host,
|
||||
clip,
|
||||
browserViewportRef.current,
|
||||
);
|
||||
rememberResolvedBrowserWebviewSize(browserIdRef.current, webview);
|
||||
});
|
||||
|
||||
@@ -882,6 +886,13 @@ export function BrowserPane({
|
||||
};
|
||||
const handleWebviewFocus = () => {
|
||||
onFocusPane?.();
|
||||
webview.focus?.();
|
||||
const focusBrowser = getDesktopHost()?.browser?.focus;
|
||||
if (typeof focusBrowser === "function") {
|
||||
void focusBrowser(browserIdRef.current).catch((error) => {
|
||||
console.error("[browser-webview] focus failed", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
webview.addEventListener("did-start-loading", handleStartLoading);
|
||||
@@ -946,11 +957,10 @@ export function BrowserPane({
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return;
|
||||
}
|
||||
applyBrowserWebviewViewport(webview, browserViewport);
|
||||
const host = webviewHostRef.current;
|
||||
const clip = webviewClipRef.current;
|
||||
if (host && clip) {
|
||||
presentBrowserWebview(browserId, webview, host, clip);
|
||||
presentBrowserWebview(browserId, webview, host, clip, browserViewport);
|
||||
}
|
||||
if (browserViewport.mode === "fixed") {
|
||||
rememberBrowserWebviewSize({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyBrowserWebviewViewport,
|
||||
applyInactiveBrowserWebviewViewport,
|
||||
type BrowserWebviewProfileHost,
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
@@ -141,7 +140,9 @@ describe("resident browser webviews", () => {
|
||||
}
|
||||
const permanentParent = webview.parentElement;
|
||||
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip, {
|
||||
mode: "responsive",
|
||||
});
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
expect(permanentParent.style.left).toBe("40px");
|
||||
expect(permanentParent.style.top).toBe("60px");
|
||||
@@ -149,12 +150,17 @@ describe("resident browser webviews", () => {
|
||||
expect(permanentParent.style.height).toBe("480px");
|
||||
expect(permanentParent.style.pointerEvents).toBe("auto");
|
||||
expect(permanentParent.getAttribute("aria-hidden")).toBe("false");
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("640px");
|
||||
expect(webview.style.height).toBe("480px");
|
||||
|
||||
releaseResidentBrowserWebview("browser-stable-parent", webview);
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
expectParkedSurface(permanentParent);
|
||||
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip, {
|
||||
mode: "responsive",
|
||||
});
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
});
|
||||
|
||||
@@ -167,7 +173,6 @@ describe("resident browser webviews", () => {
|
||||
if (!webview?.parentElement) {
|
||||
throw new Error("Expected resident browser surface");
|
||||
}
|
||||
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 2560, height: 1440 });
|
||||
const anchor = document.createElement("div");
|
||||
const clip = document.createElement("div");
|
||||
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||
@@ -177,7 +182,11 @@ describe("resident browser webviews", () => {
|
||||
value: () => ({ left: 100, top: 150, width: 800, height: 600 }),
|
||||
});
|
||||
|
||||
presentBrowserWebview("browser-oversized", webview, anchor, clip);
|
||||
presentBrowserWebview("browser-oversized", webview, anchor, clip, {
|
||||
mode: "fixed",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
});
|
||||
|
||||
expect(webview.parentElement.style.left).toBe("100px");
|
||||
expect(webview.parentElement.style.top).toBe("150px");
|
||||
@@ -359,8 +368,20 @@ describe("resident browser webviews", () => {
|
||||
|
||||
it("applies an exact fixed viewport without a flex width override", () => {
|
||||
const webview = document.createElement("webview");
|
||||
const anchor = document.createElement("div");
|
||||
const clip = document.createElement("div");
|
||||
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, top: 0, width: 640, height: 480 }),
|
||||
});
|
||||
Object.defineProperty(clip, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, top: 0, width: 640, height: 480 }),
|
||||
});
|
||||
|
||||
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 640, height: 480 });
|
||||
presentBrowserWebview("browser-fixed", webview, anchor, clip, {
|
||||
mode: "fixed",
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("640px");
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type DesktopBrowserBridge,
|
||||
} from "@/desktop/host";
|
||||
import type { BrowserViewport } from "@/desktop/browser/store";
|
||||
import { WEB_SURFACE_PLANE } from "@/lib/overlay-root";
|
||||
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
@@ -108,7 +109,7 @@ function applyResidentHostParkingStyle(host: HTMLElement): void {
|
||||
host.style.opacity = "1";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.display = "block";
|
||||
host.style.zIndex = "0";
|
||||
host.style.zIndex = String(WEB_SURFACE_PLANE.browser);
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "visible";
|
||||
host.style.transform = "";
|
||||
@@ -220,24 +221,16 @@ export function rememberBrowserWebviewSize(input: {
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
function applyBrowserWebviewDimensions(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||
function applyBrowserWebviewDimensions(
|
||||
webview: HTMLElement,
|
||||
dimensions: { width: number; height: number },
|
||||
): void {
|
||||
webview.style.display = "flex";
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
if (viewport.mode === "responsive") {
|
||||
webview.style.flex = "1";
|
||||
webview.style.width = "100%";
|
||||
webview.style.height = "100%";
|
||||
return;
|
||||
}
|
||||
webview.style.flex = "0 0 auto";
|
||||
webview.style.width = `${viewport.width}px`;
|
||||
webview.style.height = `${viewport.height}px`;
|
||||
}
|
||||
|
||||
export function applyBrowserWebviewViewport(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
applyBrowserWebviewDimensions(webview, viewport);
|
||||
webview.style.width = `${Math.max(1, Math.round(dimensions.width))}px`;
|
||||
webview.style.height = `${Math.max(1, Math.round(dimensions.height))}px`;
|
||||
}
|
||||
|
||||
export function applyInactiveBrowserWebviewViewport(
|
||||
@@ -256,6 +249,7 @@ export function presentBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
anchor: HTMLElement,
|
||||
clip: HTMLElement,
|
||||
viewport: BrowserViewport,
|
||||
): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
@@ -297,6 +291,13 @@ export function presentBrowserWebview(
|
||||
surface.style.pointerEvents = hasVisibleArea ? "auto" : "none";
|
||||
surface.style.display = "flex";
|
||||
surface.style.visibility = "visible";
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
applyBrowserWebviewDimensions(
|
||||
webview,
|
||||
viewport.mode === "responsive"
|
||||
? { width: anchorBounds.width, height: anchorBounds.height }
|
||||
: viewport,
|
||||
);
|
||||
webview.style.position = "absolute";
|
||||
webview.style.left = `${Math.round(anchorBounds.left - surfaceLeft)}px`;
|
||||
webview.style.top = `${Math.round(anchorBounds.top - surfaceTop)}px`;
|
||||
@@ -432,7 +433,7 @@ export function resizeResidentBrowserWebview(input: {
|
||||
const ownerDocument = readDocument();
|
||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||
if (webview) {
|
||||
applyBrowserWebviewDimensions(webview, { mode: "fixed", ...dimensions });
|
||||
applyBrowserWebviewDimensions(webview, dimensions);
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
||||
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
|
||||
size: theme.iconSize.md,
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export function DesktopNotificationsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, isSaving, updateSettings } = useDesktopSettings();
|
||||
const {
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
testNotificationState,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
void refreshPermissions();
|
||||
}, [refreshPermissions]);
|
||||
|
||||
const handleRequestNotifications = useCallback(() => {
|
||||
void requestPermission("notifications");
|
||||
}, [requestPermission]);
|
||||
|
||||
const handlePlaySoundChange = useCallback(
|
||||
(playSound: boolean) => {
|
||||
void updateSettings({ notifications: { playSound } }).catch(() => {
|
||||
// useDesktopSettings owns the user-visible IPC error.
|
||||
});
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleSendTestNotification = useCallback(() => {
|
||||
void sendTestNotification();
|
||||
}, [sendTestNotification]);
|
||||
|
||||
const isPermissionBusy = isRefreshing || requestingPermission !== null;
|
||||
const isSendingTestNotification = testNotificationState.status === "sending";
|
||||
const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={refreshIcon}
|
||||
onPress={handleRefreshPress}
|
||||
disabled={isPermissionBusy}
|
||||
accessibilityLabel={t("settings.notifications.refreshAccessibility")}
|
||||
>
|
||||
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
|
||||
</Button>
|
||||
),
|
||||
[handleRefreshPress, isPermissionBusy, isRefreshing, refreshIcon, t],
|
||||
);
|
||||
const permissionLabels = useMemo(
|
||||
() => ({
|
||||
granted: t("settings.permissions.actions.granted"),
|
||||
request: t("settings.permissions.actions.request"),
|
||||
requesting: t("settings.permissions.actions.requesting"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notificationsGranted = snapshot?.notifications.state === "granted";
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.notifications.title")} trailing={refreshButton}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title={t("settings.notifications.permission")}
|
||||
status={snapshot?.notifications ?? null}
|
||||
isRequesting={requestingPermission === "notifications"}
|
||||
onRequest={handleRequestNotifications}
|
||||
labels={permissionLabels}
|
||||
/>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.playSound")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.notifications.playSoundHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.notifications.playSound}
|
||||
onValueChange={handlePlaySoundChange}
|
||||
disabled={isSaving}
|
||||
accessibilityLabel={t("settings.notifications.playSound")}
|
||||
testID="desktop-notifications-play-sound-switch"
|
||||
/>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.test")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{notificationsGranted
|
||||
? t("settings.notifications.testHint")
|
||||
: t("settings.notifications.permissionRequired")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleSendTestNotification}
|
||||
disabled={!notificationsGranted || isPermissionBusy || isSendingTestNotification}
|
||||
>
|
||||
{isSendingTestNotification
|
||||
? t("settings.notifications.sending")
|
||||
: t("settings.notifications.send")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
{testNotificationState.status === "success" ? (
|
||||
<Alert
|
||||
variant="success"
|
||||
title={t("settings.notifications.sentTitle")}
|
||||
description={t("settings.notifications.sentDescription")}
|
||||
testID="desktop-notifications-test-success"
|
||||
/>
|
||||
) : null}
|
||||
{testNotificationState.status === "error" ? (
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("settings.notifications.sendFailedTitle")}
|
||||
description={testNotificationState.message}
|
||||
testID="desktop-notifications-test-error"
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -14,16 +14,16 @@ export interface DesktopPermissionRowProps {
|
||||
granted: string;
|
||||
request: string;
|
||||
requesting: string;
|
||||
busyExtraAction: (label: string) => string;
|
||||
};
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
isExtraActionBusy?: boolean;
|
||||
isExtraActionDisabled?: boolean;
|
||||
onExtraAction?: () => void;
|
||||
}
|
||||
|
||||
const ThemedCheck = withUnistyles(Check, (theme) => ({
|
||||
size: theme.iconSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
@@ -31,12 +31,7 @@ export function DesktopPermissionRow({
|
||||
labels,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
isExtraActionBusy = false,
|
||||
isExtraActionDisabled = false,
|
||||
onExtraAction,
|
||||
}: DesktopPermissionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const state = status?.state ?? "unknown";
|
||||
const isGranted = state === "granted";
|
||||
const shouldShowDetail =
|
||||
@@ -58,21 +53,9 @@ export function DesktopPermissionRow({
|
||||
</View>
|
||||
<View style={styles.permissionRowActions}>
|
||||
{isGranted ? (
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<ThemedCheck />
|
||||
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
@@ -92,11 +75,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
permissionGrantedActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
permissionStatusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
||||
@@ -9,49 +9,33 @@ import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permiss
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
|
||||
size: theme.iconSize.md,
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export function DesktopPermissionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
testNotificationError,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
const errorTextStyle = useMemo(
|
||||
() => [styles.errorText, { color: theme.colors.destructive }],
|
||||
[theme.colors.destructive],
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
void refreshPermissions();
|
||||
}, [refreshPermissions]);
|
||||
|
||||
const handleRequestNotifications = useCallback(() => {
|
||||
void requestPermission("notifications");
|
||||
}, [requestPermission]);
|
||||
|
||||
const handleRequestMicrophone = useCallback(() => {
|
||||
void requestPermission("microphone");
|
||||
}, [requestPermission]);
|
||||
|
||||
const handleSendTestNotification = useCallback(() => {
|
||||
void sendTestNotification();
|
||||
}, [sendTestNotification]);
|
||||
|
||||
const isBusy = isRefreshing || requestingPermission !== null;
|
||||
const notificationsGranted = snapshot?.notifications.state === "granted";
|
||||
|
||||
const refreshIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.md, theme.colors.foregroundMuted],
|
||||
);
|
||||
const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
@@ -74,7 +58,6 @@ export function DesktopPermissionsSection() {
|
||||
granted: t("settings.permissions.actions.granted"),
|
||||
request: t("settings.permissions.actions.request"),
|
||||
requesting: t("settings.permissions.actions.requesting"),
|
||||
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -86,21 +69,8 @@ export function DesktopPermissionsSection() {
|
||||
return (
|
||||
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title={t("settings.permissions.notifications")}
|
||||
status={snapshot?.notifications ?? null}
|
||||
isRequesting={requestingPermission === "notifications"}
|
||||
onRequest={handleRequestNotifications}
|
||||
labels={permissionLabels}
|
||||
extraActionLabel={t("settings.permissions.test")}
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={handleSendTestNotification}
|
||||
/>
|
||||
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
|
||||
<DesktopPermissionRow
|
||||
title={t("settings.permissions.microphone")}
|
||||
showBorder
|
||||
status={snapshot?.microphone ?? null}
|
||||
isRequesting={requestingPermission === "microphone"}
|
||||
onRequest={handleRequestMicrophone}
|
||||
@@ -110,11 +80,3 @@ export function DesktopPermissionsSection() {
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -151,6 +151,7 @@ export interface DesktopBrowserBridge {
|
||||
workspaceId: string;
|
||||
browserId: string | null;
|
||||
}) => Promise<void>;
|
||||
focus?: (browserId: string) => Promise<boolean>;
|
||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||
clearProfile?: (legacyBrowserIds: string[]) => Promise<void>;
|
||||
executeAutomationCommand?: (
|
||||
|
||||
@@ -14,13 +14,18 @@ export interface UseDesktopPermissionsReturn {
|
||||
snapshot: DesktopPermissionSnapshot | null;
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
isSendingTestNotification: boolean;
|
||||
testNotificationError: string | null;
|
||||
testNotificationState: TestNotificationState;
|
||||
refreshPermissions: () => Promise<void>;
|
||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||
sendTestNotification: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type TestNotificationState =
|
||||
| { status: "idle" }
|
||||
| { status: "sending" }
|
||||
| { status: "success" }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const { t } = useTranslation();
|
||||
const isDesktopApp = shouldShowDesktopPermissionSection();
|
||||
@@ -30,7 +35,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
||||
null,
|
||||
);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [testNotificationState, setTestNotificationState] = useState<TestNotificationState>({
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -111,28 +118,34 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
[isDesktopApp, refreshPermissions, t],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingTestNotification(true);
|
||||
setTestNotificationError(null);
|
||||
setTestNotificationState({ status: "sending" });
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: t("desktop.permissions.testNotification.title"),
|
||||
body: t("desktop.permissions.testNotification.body"),
|
||||
});
|
||||
if (!sent) {
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setTestNotificationState(
|
||||
sent
|
||||
? { status: "success" }
|
||||
: {
|
||||
status: "error",
|
||||
message: t("desktop.permissions.testNotification.notDelivered"),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
setTestNotificationState({
|
||||
status: "error",
|
||||
message: t("desktop.permissions.testNotification.failed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp, t]);
|
||||
@@ -150,8 +163,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
testNotificationError,
|
||||
testNotificationState,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
|
||||
@@ -13,6 +13,9 @@ const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
|
||||
|
||||
export interface DesktopSettings {
|
||||
releaseChannel: ReleaseChannel;
|
||||
notifications: {
|
||||
playSound: boolean;
|
||||
};
|
||||
daemon: {
|
||||
manageBuiltInDaemon: boolean;
|
||||
keepRunningAfterQuit: boolean;
|
||||
@@ -21,11 +24,15 @@ export interface DesktopSettings {
|
||||
|
||||
export interface DesktopSettingsPatch {
|
||||
releaseChannel?: ReleaseChannel;
|
||||
notifications?: Partial<DesktopSettings["notifications"]>;
|
||||
daemon?: Partial<DesktopSettings["daemon"]>;
|
||||
}
|
||||
|
||||
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
releaseChannel: "stable",
|
||||
notifications: {
|
||||
playSound: true,
|
||||
},
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: false,
|
||||
@@ -144,10 +151,17 @@ export async function migrateLegacyDesktopSettings(input: {
|
||||
|
||||
function parseDesktopSettings(raw: unknown): DesktopSettings {
|
||||
const record = isRecord(raw) ? raw : {};
|
||||
const notifications = isRecord(record.notifications) ? record.notifications : {};
|
||||
const daemon = isRecord(record.daemon) ? record.daemon : {};
|
||||
|
||||
return {
|
||||
releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable",
|
||||
notifications: {
|
||||
playSound:
|
||||
typeof notifications.playSound === "boolean"
|
||||
? notifications.playSound
|
||||
: DEFAULT_DESKTOP_SETTINGS.notifications.playSound,
|
||||
},
|
||||
daemon: {
|
||||
manageBuiltInDaemon:
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
@@ -167,6 +181,10 @@ function mergeDesktopSettings(
|
||||
): DesktopSettings {
|
||||
return {
|
||||
releaseChannel: updates.releaseChannel ?? current.releaseChannel,
|
||||
notifications: {
|
||||
...current.notifications,
|
||||
...updates.notifications,
|
||||
},
|
||||
daemon: {
|
||||
...current.daemon,
|
||||
...updates.daemon,
|
||||
@@ -177,6 +195,7 @@ function mergeDesktopSettings(
|
||||
function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> {
|
||||
return {
|
||||
...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}),
|
||||
...(updates.notifications ? { notifications: updates.notifications } : {}),
|
||||
...(updates.daemon ? { daemon: updates.daemon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||
});
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
@@ -63,6 +65,7 @@ function historyPayload(input: {
|
||||
entries: FetchAgentHistoryEntry[];
|
||||
hasMore?: boolean;
|
||||
nextCursor?: string | null;
|
||||
searchTruncated?: boolean;
|
||||
}): FetchAgentHistoryResult {
|
||||
return {
|
||||
requestId: "req_history",
|
||||
@@ -72,6 +75,7 @@ function historyPayload(input: {
|
||||
prevCursor: null,
|
||||
hasMore: input.hasMore ?? false,
|
||||
},
|
||||
...(input.searchTruncated === undefined ? {} : { searchTruncated: input.searchTruncated }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ function historyEntry(input: {
|
||||
updatedAt: string;
|
||||
title?: string | null;
|
||||
archivedAt?: string | null;
|
||||
searchScore?: number;
|
||||
}): FetchAgentHistoryEntry {
|
||||
return {
|
||||
agent: {
|
||||
@@ -130,6 +135,7 @@ function historyEntry(input: {
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
...(input.searchScore === undefined ? {} : { searchScore: input.searchScore }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,6 +288,276 @@ describe("fetchAgentHistoryPage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the query to the daemon rather than filtering the page locally", async () => {
|
||||
const client = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "match",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Add Stripe billing",
|
||||
searchScore: 1000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryPage({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
cursor: null,
|
||||
search: "stripe",
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.search).toBe("stripe");
|
||||
expect(page.searchScoreByAgentKey).toEqual({ "server-1:match": 1000 });
|
||||
});
|
||||
|
||||
it("keeps per-host scores apart when two hosts issue the same agent id", async () => {
|
||||
const sharedId = "collision";
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: sharedId,
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-09T10:00:00.000Z",
|
||||
title: "Weak match on A",
|
||||
searchScore: 4000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: sharedId,
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Strong match on B",
|
||||
searchScore: 1000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
search: "match",
|
||||
});
|
||||
|
||||
expect(page.searchScoreByAgentKey).toEqual({
|
||||
"server-a:collision": 4000,
|
||||
"server-b:collision": 1000,
|
||||
});
|
||||
expect(page.agents.map((agent) => agent.title)).toEqual([
|
||||
"Strong match on B",
|
||||
"Weak match on A",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports truncation when a host had more matches than its page could hold", async () => {
|
||||
const client = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "top",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Top match",
|
||||
searchScore: 1000,
|
||||
}),
|
||||
],
|
||||
searchTruncated: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
search: "match",
|
||||
});
|
||||
|
||||
expect(page.isSearchTruncated).toBe(true);
|
||||
// A ranked response promises no next page, so nothing can ask for one.
|
||||
expect(page.pageInfoByServerId["server-a"]).toEqual({
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports truncation when two complete host pages overflow the merge", async () => {
|
||||
// Neither host is locally truncated; together they exceed what the merged
|
||||
// list can show, and the footer has to say so.
|
||||
const buildHost = (serverId: string, count: number) =>
|
||||
createClient([
|
||||
historyPayload({
|
||||
entries: Array.from({ length: count }, (_, index) =>
|
||||
historyEntry({
|
||||
id: `${serverId}-${index}`,
|
||||
cwd: `/repo/${serverId}`,
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: `Match ${index}`,
|
||||
searchScore: 1000 + index,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: buildHost("server-a", 150) },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: buildHost("server-b", 150) },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
search: "match",
|
||||
});
|
||||
|
||||
expect(page.isSearchTruncated).toBe(true);
|
||||
expect(page.agents).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("names the host that failed instead of quietly shortening the list", async () => {
|
||||
const workingClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "reachable",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Reachable match",
|
||||
searchScore: 1000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
search: "match",
|
||||
});
|
||||
|
||||
expect(page.hostErrors).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
|
||||
expect(page.agents.map((agent) => agent.id)).toEqual(["reachable"]);
|
||||
});
|
||||
|
||||
it("keeps naming a host that failed page one when another host loads page two", async () => {
|
||||
// A host that rejects the first page contributes no cursor, so it is never
|
||||
// asked again. Reading only the newest page would drop its error while its
|
||||
// history stays missing, so the projection has to see every page.
|
||||
const workingClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({ id: "page-1", cwd: "/repo/a", updatedAt: "2026-04-02T10:00:00.000Z" }),
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "cursor-2",
|
||||
}),
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({ id: "page-2", cwd: "/repo/a", updatedAt: "2026-04-01T10:00:00.000Z" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const hosts = [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
|
||||
] satisfies AgentHistoryHost[];
|
||||
|
||||
const firstPage = await fetchAgentHistoryBatch({ hosts, cursorByServerId: null });
|
||||
const secondPage = await fetchAgentHistoryBatch({
|
||||
hosts,
|
||||
cursorByServerId: { "server-a": "cursor-2" },
|
||||
});
|
||||
|
||||
// Only the healthy host is re-fetched, so the second page carries no error
|
||||
// of its own. That is exactly why the screen cannot read the newest page.
|
||||
expect(secondPage.hostErrors).toEqual([]);
|
||||
expect(secondPage.agents.map((agent) => agent.id)).toEqual(["page-2"]);
|
||||
expect(
|
||||
collectAgentHistoryHostErrors({
|
||||
pages: [firstPage, secondPage],
|
||||
unreachableHosts: [],
|
||||
}),
|
||||
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
|
||||
});
|
||||
|
||||
it("reports a host that is not connected alongside one whose request failed", () => {
|
||||
expect(
|
||||
collectAgentHistoryHostErrors({
|
||||
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
|
||||
unreachableHosts: [{ serverId: "server-c", serverName: "Offline box" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{ serverId: "server-c", serverName: "Offline box" },
|
||||
{ serverId: "server-b", serverName: "Linux box" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a host once when it is both unreachable and failed", () => {
|
||||
expect(
|
||||
collectAgentHistoryHostErrors({
|
||||
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
|
||||
unreachableHosts: [{ serverId: "server-b", serverName: "Linux box" }],
|
||||
}),
|
||||
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
|
||||
});
|
||||
it("orders a searched all-host page by relevance instead of recency", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "newer-weak-match",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-09T10:00:00.000Z",
|
||||
title: "Unbilled usage report",
|
||||
searchScore: 4000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "older-strong-match",
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Bill the customer",
|
||||
searchScore: 1000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
search: "bill",
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => agent.id)).toEqual([
|
||||
"older-strong-match",
|
||||
"newer-weak-match",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
|
||||
@@ -3,11 +3,13 @@ import type {
|
||||
FetchAgentHistoryOptions,
|
||||
FetchAgentHistoryPageInfo,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
@@ -25,17 +27,52 @@ export interface AgentHistoryResult {
|
||||
isError: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
/** False when any target host predates search; the screen hides the field. */
|
||||
isSearchSupported: boolean;
|
||||
/** More sessions matched than the ranked page holds. Narrow the query. */
|
||||
isSearchTruncated: boolean;
|
||||
/** Where the query matched each row, keyed by `serverId:agentId`. */
|
||||
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
|
||||
/** Hosts that failed while others succeeded. The list still renders. */
|
||||
hostErrors: AgentHistoryHostError[];
|
||||
refreshAll: () => Promise<void>;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
export interface AgentHistoryPage {
|
||||
agents: AggregatedAgent[];
|
||||
/**
|
||||
* Relevance of each agent to the request's search, keyed by `serverId:agentId`
|
||||
* because that pair — not the bare agent id — is what identifies a row across
|
||||
* hosts. Empty without a query. Each host ranks only its own sessions, so
|
||||
* merging several of them into one list needs the scores rather than each
|
||||
* host's row order.
|
||||
*/
|
||||
searchScoreByAgentKey: Record<string, number>;
|
||||
/** Where the query matched in each row, keyed like `searchScoreByAgentKey`. */
|
||||
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
|
||||
pageInfo: FetchAgentHistoryPageInfo;
|
||||
/** More matched this host's query than its page could hold. */
|
||||
isSearchTruncated: boolean;
|
||||
}
|
||||
|
||||
/** Identity of a history row. A bare agent id belongs to exactly one host. */
|
||||
export function agentHistoryRowKey(agent: { serverId: string; id: string }): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
}
|
||||
|
||||
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
|
||||
|
||||
/**
|
||||
* A host that could not be reached while at least one other could. Its sessions
|
||||
* are missing from the list, which under a query means "no matches" would be a
|
||||
* claim the app cannot make.
|
||||
*/
|
||||
export interface AgentHistoryHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export interface AgentHistoryHost {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
@@ -44,17 +81,27 @@ export interface AgentHistoryHost {
|
||||
|
||||
interface AgentHistoryBatchPage {
|
||||
agents: AggregatedAgent[];
|
||||
searchScoreByAgentKey: Record<string, number>;
|
||||
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
|
||||
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
|
||||
hostErrors: AgentHistoryHostError[];
|
||||
/** Set only under a query: more sessions matched than this page can hold. */
|
||||
isSearchTruncated?: boolean;
|
||||
}
|
||||
|
||||
/** Sorts unmatched rows last so a page missing scores keeps its own order. */
|
||||
const UNRANKED = Number.POSITIVE_INFINITY;
|
||||
|
||||
type AgentHistoryCursorByServerId = Record<string, string | null>;
|
||||
|
||||
export async function fetchAgentHistoryPage(input: {
|
||||
client: AgentHistoryClient;
|
||||
serverId: string;
|
||||
cursor: string | null;
|
||||
search?: string;
|
||||
}): Promise<AgentHistoryPage> {
|
||||
const payload = await input.client.fetchAgentHistory({
|
||||
...(input.search ? { search: input.search } : {}),
|
||||
sort: AGENT_HISTORY_SORT,
|
||||
page: input.cursor
|
||||
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
|
||||
@@ -65,8 +112,22 @@ export async function fetchAgentHistoryPage(input: {
|
||||
serverId: input.serverId,
|
||||
entries: payload.entries,
|
||||
});
|
||||
const searchScoreByAgentKey: Record<string, number> = {};
|
||||
const searchMatchesByAgentKey: Record<string, AgentSearchMatch[]> = {};
|
||||
for (const entry of payload.entries) {
|
||||
const key = agentHistoryRowKey({ serverId: input.serverId, id: entry.agent.id });
|
||||
if (entry.searchScore !== undefined) {
|
||||
searchScoreByAgentKey[key] = entry.searchScore;
|
||||
}
|
||||
if (entry.searchMatches && entry.searchMatches.length > 0) {
|
||||
searchMatchesByAgentKey[key] = entry.searchMatches;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
searchScoreByAgentKey,
|
||||
searchMatchesByAgentKey,
|
||||
isSearchTruncated: payload.searchTruncated === true,
|
||||
agents: Array.from(agents.values(), (agent) => ({
|
||||
id: agent.id,
|
||||
serverId: input.serverId,
|
||||
@@ -94,6 +155,55 @@ function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
|
||||
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Relevance first, recency to break ties. A search result list ordered by time
|
||||
* would bury the row the user described in favour of the row they last touched.
|
||||
*
|
||||
* Truncating the merge back to one page's worth is what makes the merged list
|
||||
* globally correct: the best N matches overall are always contained in the
|
||||
* union of each host's own best N, so keeping more than N here would be showing
|
||||
* rows that a further host could still outrank.
|
||||
*/
|
||||
function mergeByRelevance(
|
||||
agents: AggregatedAgent[],
|
||||
searchScoreByAgentKey: Record<string, number>,
|
||||
limit: number,
|
||||
): AggregatedAgent[] {
|
||||
return [...agents]
|
||||
.sort((a, b) => {
|
||||
const scoreA = searchScoreByAgentKey[agentHistoryRowKey(a)] ?? UNRANKED;
|
||||
const scoreB = searchScoreByAgentKey[agentHistoryRowKey(b)] ?? UNRANKED;
|
||||
if (scoreA !== scoreB) return scoreA - scoreB;
|
||||
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which hosts are missing from the list the user is looking at.
|
||||
*
|
||||
* Failures are collected across every loaded page, not just the newest: a host
|
||||
* that rejects page one contributes no cursor and is never asked again, so a
|
||||
* later page carries no error of its own while that host's history is still
|
||||
* absent. A refetch replaces every page, which is what clears an error that has
|
||||
* healed.
|
||||
*/
|
||||
export function collectAgentHistoryHostErrors(input: {
|
||||
pages: readonly Pick<AgentHistoryBatchPage, "hostErrors">[];
|
||||
unreachableHosts: readonly AgentHistoryHostError[];
|
||||
}): AgentHistoryHostError[] {
|
||||
const byServerId = new Map<string, AgentHistoryHostError>();
|
||||
for (const error of input.unreachableHosts) {
|
||||
byServerId.set(error.serverId, error);
|
||||
}
|
||||
for (const page of input.pages) {
|
||||
for (const error of page.hostErrors) {
|
||||
byServerId.set(error.serverId, error);
|
||||
}
|
||||
}
|
||||
return [...byServerId.values()];
|
||||
}
|
||||
|
||||
function getNextAgentHistoryPageParam(
|
||||
page: AgentHistoryBatchPage,
|
||||
): AgentHistoryCursorByServerId | null {
|
||||
@@ -110,6 +220,7 @@ function getNextAgentHistoryPageParam(
|
||||
export async function fetchAgentHistoryBatch(input: {
|
||||
hosts: readonly AgentHistoryHost[];
|
||||
cursorByServerId: AgentHistoryCursorByServerId | null;
|
||||
search?: string;
|
||||
}): Promise<AgentHistoryBatchPage> {
|
||||
const cursorByServerId = input.cursorByServerId ?? {};
|
||||
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
|
||||
@@ -123,6 +234,7 @@ export async function fetchAgentHistoryBatch(input: {
|
||||
client: host.client,
|
||||
serverId: host.serverId,
|
||||
cursor: cursorByServerId[host.serverId] ?? null,
|
||||
...(input.search ? { search: input.search } : {}),
|
||||
});
|
||||
return { host, page };
|
||||
}),
|
||||
@@ -133,6 +245,18 @@ export async function fetchAgentHistoryBatch(input: {
|
||||
if (pages.length === 0) {
|
||||
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
|
||||
}
|
||||
// allSettled preserves order, so a rejection still names the host it came
|
||||
// from. Losing that name is what would let the list quietly under-report.
|
||||
const hostErrors = settledPages.flatMap((result, index) =>
|
||||
result.status === "rejected"
|
||||
? [
|
||||
{
|
||||
serverId: hostsToFetch[index].serverId,
|
||||
serverName: hostsToFetch[index].serverLabel,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const agents = pages.flatMap(({ host, page }) =>
|
||||
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
|
||||
@@ -140,16 +264,37 @@ export async function fetchAgentHistoryBatch(input: {
|
||||
const pageInfoByServerId = Object.fromEntries(
|
||||
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
|
||||
);
|
||||
const searchScoreByAgentKey = Object.assign(
|
||||
{},
|
||||
...pages.map(({ page }) => page.searchScoreByAgentKey),
|
||||
) as Record<string, number>;
|
||||
const searchMatchesByAgentKey = Object.assign(
|
||||
{},
|
||||
...pages.map(({ page }) => page.searchMatchesByAgentKey),
|
||||
) as Record<string, AgentSearchMatch[]>;
|
||||
// Truncation has two independent sources: one host overflowed its own page,
|
||||
// or several hosts each fit but their union does not. Two hosts returning 150
|
||||
// matches apiece are both complete and still add up to more than the merge
|
||||
// can show.
|
||||
const truncated =
|
||||
pages.some(({ page }) => page.isSearchTruncated) || agents.length > AGENT_HISTORY_PAGE_LIMIT;
|
||||
|
||||
return {
|
||||
agents: sortByLatestActivity(agents),
|
||||
agents: input.search
|
||||
? mergeByRelevance(agents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT)
|
||||
: sortByLatestActivity(agents),
|
||||
searchScoreByAgentKey,
|
||||
searchMatchesByAgentKey,
|
||||
pageInfoByServerId,
|
||||
hostErrors,
|
||||
...(input.search ? { isSearchTruncated: truncated } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentHistory(options: {
|
||||
serverId?: string | null;
|
||||
enabled?: boolean;
|
||||
search?: string;
|
||||
}): AgentHistoryResult {
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
@@ -164,31 +309,54 @@ export function useAgentHistory(options: {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options.serverId]);
|
||||
const enabled = options.enabled ?? true;
|
||||
const targetHosts = useMemo(() => {
|
||||
// A host the user asked about splits two ways: one this fetch can reach, and
|
||||
// one whose sessions will be missing from the answer. Both come out of here,
|
||||
// because dropping the unreachable ones silently is what lets the list — and
|
||||
// worse, "No sessions match" — overstate what was actually searched.
|
||||
const { targetHosts, unreachableHosts } = useMemo(() => {
|
||||
void runtimeVersion;
|
||||
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
|
||||
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
|
||||
const hosts: AgentHistoryHost[] = [];
|
||||
const unreachable: AgentHistoryHostError[] = [];
|
||||
|
||||
for (const targetServerId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(targetServerId);
|
||||
const client = runtime.getClient(targetServerId);
|
||||
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
unreachable.push({ serverId: targetServerId, serverName });
|
||||
continue;
|
||||
}
|
||||
hosts.push({
|
||||
serverId: targetServerId,
|
||||
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
|
||||
client,
|
||||
});
|
||||
hosts.push({ serverId: targetServerId, serverLabel: serverName, client });
|
||||
}
|
||||
|
||||
return hosts;
|
||||
return { targetHosts: hosts, unreachableHosts: unreachable };
|
||||
}, [daemons, runtime, runtimeVersion, serverId]);
|
||||
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
|
||||
// One gate, checked before the field is offered: a fleet where any host
|
||||
// predates search has no search, rather than a list that silently omits that
|
||||
// host's sessions.
|
||||
const isSearchSupported = useSessionStore(
|
||||
useCallback(
|
||||
(state) =>
|
||||
targetServerIds.length > 0 &&
|
||||
targetServerIds.every(
|
||||
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
|
||||
),
|
||||
[targetServerIds],
|
||||
),
|
||||
);
|
||||
const search = useMemo(() => {
|
||||
const trimmed = options.search?.trim() ?? "";
|
||||
return isSearchSupported ? trimmed : "";
|
||||
}, [isSearchSupported, options.search]);
|
||||
const queryKey = useMemo(
|
||||
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
|
||||
[serverId, targetServerIds],
|
||||
() => [
|
||||
...(serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
|
||||
search,
|
||||
],
|
||||
[search, serverId, targetServerIds],
|
||||
);
|
||||
const serverLabelById = useMemo(
|
||||
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
|
||||
@@ -214,6 +382,7 @@ export function useAgentHistory(options: {
|
||||
return fetchAgentHistoryBatch({
|
||||
hosts: targetHosts,
|
||||
cursorByServerId: pageParam,
|
||||
...(search ? { search } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -243,16 +412,39 @@ export function useAgentHistory(options: {
|
||||
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
|
||||
const pages = data?.pages ?? [];
|
||||
const historyAgents = pages.flatMap((page) => page.agents);
|
||||
const labelledAgents = historyAgents.map((agent) =>
|
||||
Object.assign({}, agent, {
|
||||
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
|
||||
}),
|
||||
);
|
||||
return sortByLatestActivity(labelledAgents);
|
||||
}, [data?.pages, serverLabelById]);
|
||||
if (!search) {
|
||||
return sortByLatestActivity(labelledAgents);
|
||||
}
|
||||
// A searched query never has a second page, so this is re-merging one page
|
||||
// after relabelling rather than stitching a stream back together.
|
||||
const searchScoreByAgentKey = Object.assign(
|
||||
{},
|
||||
...pages.map((page) => page.searchScoreByAgentKey),
|
||||
) as Record<string, number>;
|
||||
return mergeByRelevance(labelledAgents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT);
|
||||
}, [data?.pages, search, serverLabelById]);
|
||||
const isInitialLoad = isLoading && agents.length === 0;
|
||||
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
|
||||
const isSearchTruncated = Boolean(search && data?.pages.some((page) => page.isSearchTruncated));
|
||||
const searchMatchesByAgentKey = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
...(data?.pages ?? []).map((page) => page.searchMatchesByAgentKey),
|
||||
) as Record<string, AgentSearchMatch[]>,
|
||||
[data?.pages],
|
||||
);
|
||||
const hostErrors = useMemo(
|
||||
() => collectAgentHistoryHostErrors({ pages: data?.pages ?? [], unreachableHosts }),
|
||||
[data?.pages, unreachableHosts],
|
||||
);
|
||||
|
||||
return {
|
||||
agents,
|
||||
@@ -260,8 +452,14 @@ export function useAgentHistory(options: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isError,
|
||||
hasMore: hasNextPage,
|
||||
// Under a query the daemon returns the best matches and no cursor, so the
|
||||
// list is complete as far as it goes and "Load more" would be a lie.
|
||||
hasMore: search ? false : hasNextPage,
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
isSearchSupported,
|
||||
isSearchTruncated,
|
||||
searchMatchesByAgentKey,
|
||||
hostErrors,
|
||||
refreshAll,
|
||||
loadMore,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Trails `value` by `delayMs`, so a value that drives a request settles before
|
||||
* it is sent. The current value stays the caller's — this only decides when the
|
||||
* expensive consumer gets to see it.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// A touch tablet running the desktop layout is still `isWeb === false || true`
|
||||
// depending on the build, so neither platform gate answers "can this user
|
||||
// hover and point precisely". Only the media query does.
|
||||
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
function getFinePointerQueryList(): MediaQueryList | null {
|
||||
if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return null;
|
||||
}
|
||||
return window.matchMedia(FINE_POINTER_QUERY);
|
||||
}
|
||||
|
||||
/** True when a mouse-like pointer drives the UI. Always false on native. */
|
||||
export function hasFinePointer(): boolean {
|
||||
return getFinePointerQueryList()?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `hasFinePointer()` — re-renders when the user switches between a
|
||||
* mouse and touch (detachable keyboards, tablet mode).
|
||||
*/
|
||||
export function useHasFinePointer(): boolean {
|
||||
const [finePointer, setFinePointer] = useState(hasFinePointer);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = getFinePointerQueryList();
|
||||
if (!mediaQuery) return;
|
||||
const handleChange = () => setFinePointer(mediaQuery.matches);
|
||||
handleChange();
|
||||
mediaQuery.addEventListener?.("change", handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener?.("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return finePointer;
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
createShortcutOverrideStore,
|
||||
type ShortcutOverrideStore,
|
||||
} from "@/keyboard/shortcut-override-store";
|
||||
|
||||
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const QUERY_KEY = ["keyboard-shortcut-overrides"];
|
||||
|
||||
const EMPTY_OVERRIDES: Record<string, string> = {};
|
||||
const EMPTY_OVERRIDES: ShortcutOverrides = {};
|
||||
|
||||
export interface UseKeyboardShortcutOverridesReturn {
|
||||
overrides: Record<string, string>;
|
||||
overrides: ShortcutOverrides;
|
||||
isLoading: boolean;
|
||||
setOverride: (bindingId: string, comboString: string) => Promise<void>;
|
||||
clearOverride: (bindingId: string) => Promise<void>;
|
||||
removeOverride: (bindingId: string) => Promise<void>;
|
||||
resetAll: () => Promise<void>;
|
||||
hasOverrides: boolean;
|
||||
@@ -25,48 +30,55 @@ export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesRetu
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const setOverride = useCallback(
|
||||
async (bindingId: string, comboString: string) => {
|
||||
const prev = queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const next = { ...prev, [bindingId]: comboString };
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const removeOverride = useCallback(
|
||||
async (bindingId: string) => {
|
||||
const prev = queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const { [bindingId]: _, ...next } = prev;
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const resetAll = useCallback(async () => {
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, EMPTY_OVERRIDES);
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}, [queryClient]);
|
||||
|
||||
const store = getStore(queryClient);
|
||||
const overrides = data ?? EMPTY_OVERRIDES;
|
||||
|
||||
return {
|
||||
overrides,
|
||||
isLoading: isPending,
|
||||
setOverride,
|
||||
removeOverride,
|
||||
resetAll,
|
||||
setOverride: store.set,
|
||||
clearOverride: store.clear,
|
||||
removeOverride: store.remove,
|
||||
resetAll: store.resetAll,
|
||||
hasOverrides: Object.keys(overrides).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOverridesFromStorage(): Promise<Record<string, string>> {
|
||||
/**
|
||||
* One store per query client. Every component that calls the hook has to share
|
||||
* a single write queue -- a store per hook instance would give each its own
|
||||
* queue and reintroduce the interleaving the store exists to prevent.
|
||||
*/
|
||||
const stores = new WeakMap<QueryClient, ShortcutOverrideStore>();
|
||||
|
||||
function getStore(queryClient: QueryClient): ShortcutOverrideStore {
|
||||
const existing = stores.get(queryClient);
|
||||
if (existing) return existing;
|
||||
|
||||
const created = createShortcutOverrideStore({
|
||||
cache: {
|
||||
read: () => queryClient.getQueryData<ShortcutOverrides>(QUERY_KEY) ?? EMPTY_OVERRIDES,
|
||||
write: (next) => {
|
||||
queryClient.setQueryData<ShortcutOverrides>(QUERY_KEY, next);
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
write: (serialized) => AsyncStorage.setItem(STORAGE_KEY, serialized),
|
||||
remove: () => AsyncStorage.removeItem(STORAGE_KEY),
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("[KeyboardShortcutOverrides] Failed to save overrides:", err);
|
||||
},
|
||||
});
|
||||
stores.set(queryClient, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function loadOverridesFromStorage(): Promise<ShortcutOverrides> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as Record<string, string>;
|
||||
return JSON.parse(stored) as ShortcutOverrides;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
|
||||
|
||||
@@ -71,6 +71,7 @@ export function useKeyboardShortcuts({
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const badgeModifierKeyRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
@@ -110,7 +111,13 @@ export function useKeyboardShortcuts({
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey({ isMac, isDesktop: isDesktopApp });
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
@@ -120,6 +127,16 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
};
|
||||
|
||||
// The keyup listener matches the released key against the modifier derived
|
||||
// when the effect last ran, so a modifier held while the jump binding
|
||||
// changes can never be released -- the badges would stay up until a blur.
|
||||
// Clear on change only: this effect also re-runs on every navigation, and
|
||||
// clearing unconditionally would drop the badges mid Cmd+1, Cmd+2.
|
||||
if (badgeModifierKeyRef.current !== badgeModifierKey) {
|
||||
badgeModifierKeyRef.current = badgeModifierKey;
|
||||
resetModifiers();
|
||||
}
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface FakeDesktopBridge extends DesktopSettingsBridge {
|
||||
|
||||
const DEFAULT_DESKTOP: DesktopSettings = {
|
||||
releaseChannel: "stable",
|
||||
notifications: {
|
||||
playSound: true,
|
||||
},
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: false,
|
||||
|
||||
@@ -255,6 +255,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
isElectron: true,
|
||||
settings: {
|
||||
releaseChannel: "beta",
|
||||
notifications: { playSound: true },
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -246,7 +246,9 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.actions.rebind).toBe("Rebind");
|
||||
expect(en.settings.integrations.commandLine.title).toBe("Command line");
|
||||
expect(en.settings.integrations.skills.updateAvailable).toBe("Update available");
|
||||
expect(en.settings.permissions.notifications).toBe("Notifications");
|
||||
expect(en.settings.notifications.playSound).toBe("Play sound");
|
||||
expect(en.settings.notifications.permission).toBe("Notification permission");
|
||||
expect(en.settings.notifications.sentTitle).toBe("Test notification sent");
|
||||
expect(en.settings.permissions.actions.request).toBe("Request");
|
||||
});
|
||||
|
||||
|
||||
@@ -231,8 +231,13 @@ export const ar: TranslationResources = {
|
||||
sessions: {
|
||||
title: "السجل",
|
||||
empty: "لا توجد جلسات بعد",
|
||||
noMatches: "لا توجد جلسات مطابقة",
|
||||
tooManyMatches: "نتائج كثيرة جدًا — ضيّق نطاق البحث",
|
||||
hostLoadFailed: "{{host}}: تعذر تحميل السجل",
|
||||
searchPlaceholder: "البحث في السجل",
|
||||
actions: {
|
||||
loadMore: "تحميل المزيد",
|
||||
clearSearch: "مسح البحث",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -949,7 +954,7 @@ export const ar: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "إنشاء مشكلة على GitHub",
|
||||
whatsNew: "ما الجديد",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "السجل",
|
||||
@@ -1238,7 +1243,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "حدث خطأ",
|
||||
title: "واجه Paseo مشكلة.",
|
||||
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
|
||||
details: "التفاصيل",
|
||||
@@ -1699,6 +1703,7 @@ export const ar: TranslationResources = {
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
notifications: "الإشعارات",
|
||||
permissions: "الأذونات",
|
||||
diagnostics: "التشخيص",
|
||||
about: "عن",
|
||||
@@ -1708,6 +1713,21 @@ export const ar: TranslationResources = {
|
||||
vimKeybindings: "اختصارات Vim",
|
||||
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
|
||||
},
|
||||
notifications: {
|
||||
title: "الإشعارات",
|
||||
permission: "إذن الإشعارات",
|
||||
refreshAccessibility: "تحديث إذن الإشعارات",
|
||||
playSound: "تشغيل صوت",
|
||||
playSoundHint: "تشغيل صوت عند وصول إشعار سطح مكتب",
|
||||
test: "اختبار الإشعار",
|
||||
testHint: "إرسال إشعار باستخدام هذه الإعدادات",
|
||||
permissionRequired: "اسمح بالوصول إلى الإشعارات قبل الاختبار",
|
||||
send: "إرسال",
|
||||
sending: "جارٍ الإرسال...",
|
||||
sentTitle: "تم إرسال إشعار الاختبار",
|
||||
sentDescription: "سلّم Paseo الإشعار إلى نظام التشغيل.",
|
||||
sendFailedTitle: "تعذر إرسال إشعار الاختبار",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "المشاريع",
|
||||
connections: "اتصالات",
|
||||
@@ -1854,6 +1874,7 @@ export const ar: TranslationResources = {
|
||||
midnight: "منتصف الليل",
|
||||
claude: "كلود",
|
||||
ghostty: "شبحي",
|
||||
pureBlack: "أسود خالص",
|
||||
auto: "نظام",
|
||||
},
|
||||
},
|
||||
@@ -1892,11 +1913,15 @@ export const ar: TranslationResources = {
|
||||
searchPlaceholder: "البحث في الاختصارات",
|
||||
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
|
||||
capturePrompt: "اضغط على الاختصار...",
|
||||
unassigned: "غير معين",
|
||||
actions: {
|
||||
menu: "إجراءات {{name}}",
|
||||
done: "منتهي",
|
||||
cancel: "يلغي",
|
||||
bind: "ربط",
|
||||
rebind: "إعادة ربط",
|
||||
reset: "إعادة ضبط",
|
||||
clear: "مسح",
|
||||
reset: "إعادة الضبط إلى الافتراضي",
|
||||
resetAll: "إعادة ضبط الكل",
|
||||
},
|
||||
sections: {
|
||||
@@ -2002,17 +2027,14 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "الأذونات",
|
||||
notifications: "إشعارات",
|
||||
microphone: "ميكروفون",
|
||||
refresh: "ينعش",
|
||||
refreshing: "منعش...",
|
||||
refreshAccessibility: "تحديث أذونات سطح المكتب",
|
||||
test: "امتحان",
|
||||
actions: {
|
||||
granted: "ممنوح",
|
||||
request: "طلب",
|
||||
requesting: "جارٍ الطلب...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -230,8 +230,13 @@ export const en = {
|
||||
sessions: {
|
||||
title: "History",
|
||||
empty: "No sessions yet",
|
||||
noMatches: "No sessions match",
|
||||
tooManyMatches: "Too many matches — narrow your search",
|
||||
hostLoadFailed: "{{host}}: Could not load history",
|
||||
searchPlaceholder: "Search history",
|
||||
actions: {
|
||||
loadMore: "Load more",
|
||||
clearSearch: "Clear search",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -959,7 +964,7 @@ export const en = {
|
||||
discord: "Discord",
|
||||
github: "Create GitHub issue",
|
||||
whatsNew: "What's new",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "History",
|
||||
@@ -1248,7 +1253,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "Something went wrong",
|
||||
title: "Paseo ran into a problem.",
|
||||
body: "Try again to reload the app. If this keeps happening, include the details below when you report it.",
|
||||
details: "Details",
|
||||
@@ -1710,6 +1714,7 @@ export const en = {
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
notifications: "Notifications",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
@@ -1719,6 +1724,21 @@ export const en = {
|
||||
vimKeybindings: "Vim keybindings",
|
||||
vimHint: "Applies to source files on web and desktop.",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
permission: "Notification permission",
|
||||
refreshAccessibility: "Refresh notification permission",
|
||||
playSound: "Play sound",
|
||||
playSoundHint: "Play a sound when a desktop notification arrives",
|
||||
test: "Test notification",
|
||||
testHint: "Send a notification using these settings",
|
||||
permissionRequired: "Allow notification access before testing",
|
||||
send: "Send",
|
||||
sending: "Sending...",
|
||||
sentTitle: "Test notification sent",
|
||||
sentDescription: "Paseo handed the notification to the operating system.",
|
||||
sendFailedTitle: "Unable to send test notification",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "Projects",
|
||||
connections: "Connections",
|
||||
@@ -1864,6 +1884,7 @@ export const en = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "Pure black",
|
||||
auto: "System",
|
||||
},
|
||||
},
|
||||
@@ -1902,11 +1923,15 @@ export const en = {
|
||||
searchPlaceholder: "Search shortcuts",
|
||||
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
|
||||
capturePrompt: "Press shortcut...",
|
||||
unassigned: "Not set",
|
||||
actions: {
|
||||
menu: "Actions for {{name}}",
|
||||
done: "Done",
|
||||
cancel: "Cancel",
|
||||
bind: "Bind",
|
||||
rebind: "Rebind",
|
||||
reset: "Reset",
|
||||
clear: "Clear",
|
||||
reset: "Reset to default",
|
||||
resetAll: "Reset all",
|
||||
},
|
||||
sections: {
|
||||
@@ -2013,17 +2038,14 @@ export const en = {
|
||||
},
|
||||
permissions: {
|
||||
title: "Permissions",
|
||||
notifications: "Notifications",
|
||||
microphone: "Microphone",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
refreshAccessibility: "Refresh desktop permissions",
|
||||
test: "Test",
|
||||
actions: {
|
||||
granted: "Granted",
|
||||
request: "Request",
|
||||
requesting: "Requesting...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -234,8 +234,13 @@ export const es: TranslationResources = {
|
||||
sessions: {
|
||||
title: "Historial",
|
||||
empty: "Aún no hay sesiones",
|
||||
noMatches: "No hay sesiones que coincidan",
|
||||
tooManyMatches: "Demasiadas coincidencias: acota la búsqueda",
|
||||
hostLoadFailed: "{{host}}: No se pudo cargar el historial",
|
||||
searchPlaceholder: "Buscar en el historial",
|
||||
actions: {
|
||||
loadMore: "Cargar más",
|
||||
clearSearch: "Borrar búsqueda",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -980,7 +985,7 @@ export const es: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "Crear incidencia en GitHub",
|
||||
whatsNew: "Novedades",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historial",
|
||||
@@ -1278,7 +1283,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "Algo salió mal",
|
||||
title: "Paseo tuvo un problema.",
|
||||
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
|
||||
details: "Detalles",
|
||||
@@ -1742,6 +1746,7 @@ export const es: TranslationResources = {
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
notifications: "Notificaciones",
|
||||
permissions: "Permisos",
|
||||
diagnostics: "Diagnóstico",
|
||||
about: "Acerca de",
|
||||
@@ -1751,6 +1756,21 @@ export const es: TranslationResources = {
|
||||
vimKeybindings: "Atajos de Vim",
|
||||
vimHint: "Se aplica a archivos fuente en web y escritorio.",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notificaciones",
|
||||
permission: "Permiso de notificaciones",
|
||||
refreshAccessibility: "Actualizar permiso de notificaciones",
|
||||
playSound: "Reproducir sonido",
|
||||
playSoundHint: "Reproduce un sonido cuando llega una notificación de escritorio",
|
||||
test: "Probar notificación",
|
||||
testHint: "Envía una notificación con esta configuración",
|
||||
permissionRequired: "Permite el acceso a las notificaciones antes de probar",
|
||||
send: "Enviar",
|
||||
sending: "Enviando...",
|
||||
sentTitle: "Notificación de prueba enviada",
|
||||
sentDescription: "Paseo entregó la notificación al sistema operativo.",
|
||||
sendFailedTitle: "No se pudo enviar la notificación de prueba",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "Proyectos",
|
||||
connections: "Conexiones",
|
||||
@@ -1901,6 +1921,7 @@ export const es: TranslationResources = {
|
||||
midnight: "Medianoche",
|
||||
claude: "claudio",
|
||||
ghostty: "fantasmal",
|
||||
pureBlack: "Negro puro",
|
||||
auto: "Sistema",
|
||||
},
|
||||
},
|
||||
@@ -1940,11 +1961,15 @@ export const es: TranslationResources = {
|
||||
searchPlaceholder: "Buscar atajos",
|
||||
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
|
||||
capturePrompt: "Presione el acceso directo...",
|
||||
unassigned: "Sin asignar",
|
||||
actions: {
|
||||
menu: "Acciones para {{name}}",
|
||||
done: "Hecho",
|
||||
cancel: "Cancelar",
|
||||
bind: "Asignar",
|
||||
rebind: "Reencuadernar",
|
||||
reset: "Reiniciar",
|
||||
clear: "Borrar",
|
||||
reset: "Restablecer al valor predeterminado",
|
||||
resetAll: "Restablecer todo",
|
||||
},
|
||||
sections: {
|
||||
@@ -2052,17 +2077,14 @@ export const es: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "Permisos",
|
||||
notifications: "Notificaciones",
|
||||
microphone: "Micrófono",
|
||||
refresh: "Refrescar",
|
||||
refreshing: "Refrescante...",
|
||||
refreshAccessibility: "Actualizar permisos de escritorio",
|
||||
test: "Prueba",
|
||||
actions: {
|
||||
granted: "Otorgada",
|
||||
request: "Pedido",
|
||||
requesting: "Solicitando...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -235,8 +235,13 @@ export const fr: TranslationResources = {
|
||||
sessions: {
|
||||
title: "Historique",
|
||||
empty: "Aucune séance pour l'instant",
|
||||
noMatches: "Aucune séance correspondante",
|
||||
tooManyMatches: "Trop de résultats — affinez la recherche",
|
||||
hostLoadFailed: "{{host}} : Impossible de charger l'historique",
|
||||
searchPlaceholder: "Rechercher dans l'historique",
|
||||
actions: {
|
||||
loadMore: "Charger plus",
|
||||
clearSearch: "Effacer la recherche",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -979,7 +984,7 @@ export const fr: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "Créer un ticket GitHub",
|
||||
whatsNew: "Nouveautés",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historique",
|
||||
@@ -1281,7 +1286,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "Une erreur s'est produite",
|
||||
title: "Paseo a rencontré un problème.",
|
||||
body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.",
|
||||
details: "Détails",
|
||||
@@ -1746,6 +1750,7 @@ export const fr: TranslationResources = {
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
notifications: "Notifications",
|
||||
permissions: "Autorisations",
|
||||
diagnostics: "Diagnostic",
|
||||
about: "À propos",
|
||||
@@ -1755,6 +1760,21 @@ export const fr: TranslationResources = {
|
||||
vimKeybindings: "Raccourcis Vim",
|
||||
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
permission: "Autorisation des notifications",
|
||||
refreshAccessibility: "Actualiser l’autorisation des notifications",
|
||||
playSound: "Émettre un son",
|
||||
playSoundHint: "Émet un son lorsqu’une notification de bureau arrive",
|
||||
test: "Tester la notification",
|
||||
testHint: "Envoie une notification avec ces réglages",
|
||||
permissionRequired: "Autorisez l’accès aux notifications avant le test",
|
||||
send: "Envoyer",
|
||||
sending: "Envoi...",
|
||||
sentTitle: "Notification de test envoyée",
|
||||
sentDescription: "Paseo a transmis la notification au système d’exploitation.",
|
||||
sendFailedTitle: "Impossible d’envoyer la notification de test",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "Projets",
|
||||
connections: "Relations",
|
||||
@@ -1904,6 +1924,7 @@ export const fr: TranslationResources = {
|
||||
midnight: "Minuit",
|
||||
claude: "Claude",
|
||||
ghostty: "Fantôme",
|
||||
pureBlack: "Noir pur",
|
||||
auto: "Système",
|
||||
},
|
||||
},
|
||||
@@ -1943,11 +1964,15 @@ export const fr: TranslationResources = {
|
||||
searchPlaceholder: "Rechercher des raccourcis",
|
||||
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau",
|
||||
capturePrompt: "Appuyez sur le raccourci...",
|
||||
unassigned: "Non défini",
|
||||
actions: {
|
||||
menu: "Actions pour {{name}}",
|
||||
done: "Fait",
|
||||
cancel: "Annuler",
|
||||
bind: "Attribuer",
|
||||
rebind: "Relier",
|
||||
reset: "Réinitialiser",
|
||||
clear: "Effacer",
|
||||
reset: "Rétablir la valeur par défaut",
|
||||
resetAll: "Tout réinitialiser",
|
||||
},
|
||||
sections: {
|
||||
@@ -2056,17 +2081,14 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "Autorisations",
|
||||
notifications: "Notifications",
|
||||
microphone: "Microphone",
|
||||
refresh: "Rafraîchir",
|
||||
refreshing: "Rafraîchissant...",
|
||||
refreshAccessibility: "Actualiser les autorisations du bureau",
|
||||
test: "Test",
|
||||
actions: {
|
||||
granted: "Accordé",
|
||||
request: "Demande",
|
||||
requesting: "Demander...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -234,8 +234,13 @@ export const ja: TranslationResources = {
|
||||
sessions: {
|
||||
title: "履歴",
|
||||
empty: "セッションがまだありません",
|
||||
noMatches: "一致するセッションはありません",
|
||||
tooManyMatches: "一致が多すぎます — 検索条件を絞ってください",
|
||||
hostLoadFailed: "{{host}}: 履歴を読み込めませんでした",
|
||||
searchPlaceholder: "履歴を検索",
|
||||
actions: {
|
||||
loadMore: "さらに読み込む",
|
||||
clearSearch: "検索をクリア",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -960,7 +965,7 @@ export const ja: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "GitHub Issueを作成",
|
||||
whatsNew: "新着情報",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "履歴",
|
||||
@@ -1251,7 +1256,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "問題が発生しました",
|
||||
title: "Paseo で問題が発生しました。",
|
||||
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
|
||||
details: "詳細",
|
||||
@@ -1715,6 +1719,7 @@ export const ja: TranslationResources = {
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
notifications: "通知",
|
||||
permissions: "権限",
|
||||
diagnostics: "診断",
|
||||
about: "アプリ情報",
|
||||
@@ -1724,6 +1729,21 @@ export const ja: TranslationResources = {
|
||||
vimKeybindings: "Vim キーバインド",
|
||||
vimHint: "Web とデスクトップのソースファイルに適用されます。",
|
||||
},
|
||||
notifications: {
|
||||
title: "通知",
|
||||
permission: "通知の権限",
|
||||
refreshAccessibility: "通知の権限を更新",
|
||||
playSound: "サウンドを再生",
|
||||
playSoundHint: "デスクトップ通知が届いたときにサウンドを再生します",
|
||||
test: "通知をテスト",
|
||||
testHint: "現在の設定でテスト通知を送信します",
|
||||
permissionRequired: "テストする前に通知へのアクセスを許可してください",
|
||||
send: "送信",
|
||||
sending: "送信中...",
|
||||
sentTitle: "テスト通知を送信しました",
|
||||
sentDescription: "Paseo が通知をオペレーティングシステムに渡しました。",
|
||||
sendFailedTitle: "テスト通知を送信できません",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "プロジェクト",
|
||||
connections: "接続",
|
||||
@@ -1869,6 +1889,7 @@ export const ja: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "ピュアブラック",
|
||||
auto: "システム",
|
||||
},
|
||||
},
|
||||
@@ -1908,11 +1929,15 @@ export const ja: TranslationResources = {
|
||||
searchPlaceholder: "ショートカットを検索",
|
||||
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
|
||||
capturePrompt: "ショートカットを押してください...",
|
||||
unassigned: "未設定",
|
||||
actions: {
|
||||
menu: "{{name}} のアクション",
|
||||
done: "完了",
|
||||
cancel: "キャンセル",
|
||||
bind: "割り当て",
|
||||
rebind: "再割り当て",
|
||||
reset: "リセット",
|
||||
clear: "クリア",
|
||||
reset: "デフォルトに戻す",
|
||||
resetAll: "すべてリセット",
|
||||
},
|
||||
sections: {
|
||||
@@ -2021,17 +2046,14 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "権限",
|
||||
notifications: "通知",
|
||||
microphone: "マイク",
|
||||
refresh: "更新",
|
||||
refreshing: "更新中...",
|
||||
refreshAccessibility: "デスクトップの権限を更新",
|
||||
test: "テスト",
|
||||
actions: {
|
||||
granted: "許可済み",
|
||||
request: "許可を求める",
|
||||
requesting: "許可を求めています...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -232,8 +232,13 @@ export const ko: TranslationResources = {
|
||||
sessions: {
|
||||
title: "기록",
|
||||
empty: "아직 세션이 없습니다",
|
||||
noMatches: "일치하는 세션이 없습니다",
|
||||
tooManyMatches: "일치 항목이 너무 많습니다 — 검색 범위를 좁히세요",
|
||||
hostLoadFailed: "{{host}}: 기록을 불러오지 못했습니다",
|
||||
searchPlaceholder: "기록 검색",
|
||||
actions: {
|
||||
loadMore: "더 불러오기",
|
||||
clearSearch: "검색 지우기",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -956,7 +961,7 @@ export const ko: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "GitHub 이슈 만들기",
|
||||
whatsNew: "새로운 소식",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "기록",
|
||||
@@ -1247,7 +1252,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "문제가 발생했습니다.",
|
||||
title: "Paseo에 문제가 발생했습니다.",
|
||||
body: "앱을 다시 로드해 보세요. 이런 일이 계속 발생하면 신고할 때 아래 세부정보를 포함하세요.",
|
||||
details: "세부",
|
||||
@@ -1709,6 +1713,7 @@ export const ko: TranslationResources = {
|
||||
editor: "편집기",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
notifications: "알림",
|
||||
permissions: "권한",
|
||||
diagnostics: "진단",
|
||||
about: "정보",
|
||||
@@ -1718,6 +1723,21 @@ export const ko: TranslationResources = {
|
||||
vimKeybindings: "Vim 키 바인딩",
|
||||
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
|
||||
},
|
||||
notifications: {
|
||||
title: "알림",
|
||||
permission: "알림 권한",
|
||||
refreshAccessibility: "알림 권한 새로 고침",
|
||||
playSound: "소리 재생",
|
||||
playSoundHint: "데스크톱 알림이 도착하면 소리를 재생합니다",
|
||||
test: "알림 테스트",
|
||||
testHint: "현재 설정으로 테스트 알림을 보냅니다",
|
||||
permissionRequired: "테스트하기 전에 알림 접근을 허용하세요",
|
||||
send: "보내기",
|
||||
sending: "보내는 중...",
|
||||
sentTitle: "테스트 알림을 보냈습니다",
|
||||
sentDescription: "Paseo가 알림을 운영 체제에 전달했습니다.",
|
||||
sendFailedTitle: "테스트 알림을 보낼 수 없음",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "프로젝트",
|
||||
connections: "연결",
|
||||
@@ -1865,6 +1885,7 @@ export const ko: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "순수 검정",
|
||||
auto: "시스템",
|
||||
},
|
||||
},
|
||||
@@ -1903,11 +1924,15 @@ export const ko: TranslationResources = {
|
||||
searchPlaceholder: "검색 단축키",
|
||||
unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다",
|
||||
capturePrompt: "단축키를 누르세요...",
|
||||
unassigned: "설정되지 않음",
|
||||
actions: {
|
||||
menu: "{{name}} 작업",
|
||||
done: "완료",
|
||||
cancel: "취소",
|
||||
bind: "지정",
|
||||
rebind: "다시 지정",
|
||||
reset: "재설정",
|
||||
clear: "지우기",
|
||||
reset: "기본값으로 재설정",
|
||||
resetAll: "모두 재설정",
|
||||
},
|
||||
sections: {
|
||||
@@ -2014,17 +2039,14 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "권한",
|
||||
notifications: "알림",
|
||||
microphone: "마이크",
|
||||
refresh: "새로고침",
|
||||
refreshing: "새로고침 중...",
|
||||
refreshAccessibility: "데스크톱 권한 새로고침",
|
||||
test: "테스트",
|
||||
actions: {
|
||||
granted: "허용됨",
|
||||
request: "요청",
|
||||
requesting: "요청 중...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -234,8 +234,13 @@ export const ptBR: TranslationResources = {
|
||||
sessions: {
|
||||
title: "Histórico de agentes",
|
||||
empty: "Nenhuma sessão ainda",
|
||||
noMatches: "Nenhuma sessão corresponde",
|
||||
tooManyMatches: "Muitos resultados — refine a busca",
|
||||
hostLoadFailed: "{{host}}: Não foi possível carregar o histórico",
|
||||
searchPlaceholder: "Buscar no histórico",
|
||||
actions: {
|
||||
loadMore: "Carregar mais",
|
||||
clearSearch: "Limpar busca",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -971,7 +976,7 @@ export const ptBR: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "Criar issue no GitHub",
|
||||
whatsNew: "Novidades",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "Histórico",
|
||||
@@ -1264,7 +1269,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "Algo deu errado",
|
||||
title: "O Paseo encontrou um problema.",
|
||||
body: "Tente novamente para recarregar o app. Se isso continuar acontecendo, inclua os detalhes abaixo ao relatar o problema.",
|
||||
details: "Detalhes",
|
||||
@@ -1728,6 +1732,7 @@ export const ptBR: TranslationResources = {
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
notifications: "Notificações",
|
||||
permissions: "Permissões",
|
||||
diagnostics: "Diagnósticos",
|
||||
about: "Sobre",
|
||||
@@ -1737,6 +1742,21 @@ export const ptBR: TranslationResources = {
|
||||
vimKeybindings: "Atalhos do Vim",
|
||||
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notificações",
|
||||
permission: "Permissão de notificações",
|
||||
refreshAccessibility: "Atualizar permissão de notificações",
|
||||
playSound: "Reproduzir som",
|
||||
playSoundHint: "Reproduz um som quando chega uma notificação da área de trabalho",
|
||||
test: "Testar notificação",
|
||||
testHint: "Envia uma notificação usando estas configurações",
|
||||
permissionRequired: "Permita o acesso às notificações antes de testar",
|
||||
send: "Enviar",
|
||||
sending: "Enviando...",
|
||||
sentTitle: "Notificação de teste enviada",
|
||||
sentDescription: "O Paseo entregou a notificação ao sistema operacional.",
|
||||
sendFailedTitle: "Não foi possível enviar a notificação de teste",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "Projetos",
|
||||
connections: "Conexões",
|
||||
@@ -1885,6 +1905,7 @@ export const ptBR: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "Preto puro",
|
||||
auto: "Sistema",
|
||||
},
|
||||
},
|
||||
@@ -1923,11 +1944,15 @@ export const ptBR: TranslationResources = {
|
||||
searchPlaceholder: "Pesquisar atalhos",
|
||||
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
|
||||
capturePrompt: "Pressione o atalho...",
|
||||
unassigned: "Não definido",
|
||||
actions: {
|
||||
menu: "Ações para {{name}}",
|
||||
done: "Concluído",
|
||||
cancel: "Cancelar",
|
||||
bind: "Atribuir",
|
||||
rebind: "Reatribuir",
|
||||
reset: "Redefinir",
|
||||
clear: "Limpar",
|
||||
reset: "Redefinir para o padrão",
|
||||
resetAll: "Redefinir tudo",
|
||||
},
|
||||
sections: {
|
||||
@@ -2036,17 +2061,14 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "Permissões",
|
||||
notifications: "Notificações",
|
||||
microphone: "Microfone",
|
||||
refresh: "Atualizar",
|
||||
refreshing: "Atualizando...",
|
||||
refreshAccessibility: "Atualizar permissões do desktop",
|
||||
test: "Testar",
|
||||
actions: {
|
||||
granted: "Concedida",
|
||||
request: "Solicitar",
|
||||
requesting: "Solicitando...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -233,8 +233,13 @@ export const ru: TranslationResources = {
|
||||
sessions: {
|
||||
title: "История",
|
||||
empty: "Сеансов пока нет",
|
||||
noMatches: "Нет подходящих сеансов",
|
||||
tooManyMatches: "Слишком много совпадений — уточните запрос",
|
||||
hostLoadFailed: "{{host}}: Не удалось загрузить историю",
|
||||
searchPlaceholder: "Поиск по истории",
|
||||
actions: {
|
||||
loadMore: "Загрузить больше",
|
||||
clearSearch: "Очистить поиск",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -971,7 +976,7 @@ export const ru: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "Создать issue в GitHub",
|
||||
whatsNew: "Что нового",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "История",
|
||||
@@ -1267,7 +1272,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "Что-то пошло не так",
|
||||
title: "В Paseo возникла проблема.",
|
||||
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
|
||||
details: "Подробности",
|
||||
@@ -1733,6 +1737,7 @@ export const ru: TranslationResources = {
|
||||
editor: "Редактор",
|
||||
shortcuts: "Ярлыки",
|
||||
integrations: "Интеграции",
|
||||
notifications: "Уведомления",
|
||||
permissions: "Разрешения",
|
||||
diagnostics: "Диагностика",
|
||||
about: "О",
|
||||
@@ -1742,6 +1747,21 @@ export const ru: TranslationResources = {
|
||||
vimKeybindings: "Клавиши Vim",
|
||||
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
|
||||
},
|
||||
notifications: {
|
||||
title: "Уведомления",
|
||||
permission: "Разрешение на уведомления",
|
||||
refreshAccessibility: "Обновить разрешение на уведомления",
|
||||
playSound: "Воспроизводить звук",
|
||||
playSoundHint: "Воспроизводит звук при получении уведомления на компьютере",
|
||||
test: "Проверить уведомление",
|
||||
testHint: "Отправляет уведомление с текущими настройками",
|
||||
permissionRequired: "Разрешите доступ к уведомлениям перед проверкой",
|
||||
send: "Отправить",
|
||||
sending: "Отправка...",
|
||||
sentTitle: "Тестовое уведомление отправлено",
|
||||
sentDescription: "Paseo передал уведомление операционной системе.",
|
||||
sendFailedTitle: "Не удалось отправить тестовое уведомление",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "Проекты",
|
||||
connections: "Соединения",
|
||||
@@ -1890,6 +1910,7 @@ export const ru: TranslationResources = {
|
||||
midnight: "Полночь",
|
||||
claude: "Клод",
|
||||
ghostty: "Призрачный",
|
||||
pureBlack: "Чистый чёрный",
|
||||
auto: "Система",
|
||||
},
|
||||
},
|
||||
@@ -1930,11 +1951,15 @@ export const ru: TranslationResources = {
|
||||
searchPlaceholder: "Поиск сочетаний клавиш",
|
||||
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
|
||||
capturePrompt: "Нажмите ярлык...",
|
||||
unassigned: "Не задано",
|
||||
actions: {
|
||||
menu: "Действия для {{name}}",
|
||||
done: "Сделанный",
|
||||
cancel: "Отмена",
|
||||
bind: "Привязать",
|
||||
rebind: "Перепривязка",
|
||||
reset: "Перезагрузить",
|
||||
clear: "Очистить",
|
||||
reset: "Сбросить к значению по умолчанию",
|
||||
resetAll: "Сбросить все",
|
||||
},
|
||||
sections: {
|
||||
@@ -2040,17 +2065,14 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "Разрешения",
|
||||
notifications: "Уведомления",
|
||||
microphone: "Микрофон",
|
||||
refresh: "Обновить",
|
||||
refreshing: "Освежающий...",
|
||||
refreshAccessibility: "Обновить разрешения рабочего стола",
|
||||
test: "Тест",
|
||||
actions: {
|
||||
granted: "Предоставленный",
|
||||
request: "Запрос",
|
||||
requesting: "Запрос...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -231,8 +231,13 @@ export const zhCN: TranslationResources = {
|
||||
sessions: {
|
||||
title: "历史",
|
||||
empty: "还没有会话",
|
||||
noMatches: "没有匹配的会话",
|
||||
tooManyMatches: "匹配过多 — 请缩小搜索范围",
|
||||
hostLoadFailed: "{{host}}:无法加载历史",
|
||||
searchPlaceholder: "搜索历史",
|
||||
actions: {
|
||||
loadMore: "加载更多",
|
||||
clearSearch: "清除搜索",
|
||||
},
|
||||
},
|
||||
agentList: {
|
||||
@@ -941,7 +946,7 @@ export const zhCN: TranslationResources = {
|
||||
discord: "Discord",
|
||||
github: "创建 GitHub Issue",
|
||||
whatsNew: "新功能",
|
||||
version: "Paseo {{version}}",
|
||||
appName: "Paseo",
|
||||
},
|
||||
sections: {
|
||||
sessions: "历史",
|
||||
@@ -1222,7 +1227,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
rootError: {
|
||||
kicker: "出现问题",
|
||||
title: "Paseo 遇到了问题。",
|
||||
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
|
||||
details: "详情",
|
||||
@@ -1679,6 +1683,7 @@ export const zhCN: TranslationResources = {
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
notifications: "通知",
|
||||
permissions: "权限",
|
||||
diagnostics: "诊断",
|
||||
about: "关于",
|
||||
@@ -1688,6 +1693,21 @@ export const zhCN: TranslationResources = {
|
||||
vimKeybindings: "Vim 键位",
|
||||
vimHint: "适用于网页和桌面端的源文件。",
|
||||
},
|
||||
notifications: {
|
||||
title: "通知",
|
||||
permission: "通知权限",
|
||||
refreshAccessibility: "刷新通知权限",
|
||||
playSound: "播放声音",
|
||||
playSoundHint: "收到桌面通知时播放声音",
|
||||
test: "测试通知",
|
||||
testHint: "使用当前设置发送测试通知",
|
||||
permissionRequired: "测试前请允许访问通知",
|
||||
send: "发送",
|
||||
sending: "正在发送...",
|
||||
sentTitle: "测试通知已发送",
|
||||
sentDescription: "Paseo 已将通知交给操作系统。",
|
||||
sendFailedTitle: "无法发送测试通知",
|
||||
},
|
||||
hostSections: {
|
||||
projects: "项目",
|
||||
connections: "连接",
|
||||
@@ -1833,6 +1853,7 @@ export const zhCN: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "纯黑",
|
||||
auto: "系统",
|
||||
},
|
||||
},
|
||||
@@ -1870,11 +1891,15 @@ export const zhCN: TranslationResources = {
|
||||
searchPlaceholder: "搜索快捷键",
|
||||
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
|
||||
capturePrompt: "按下快捷键...",
|
||||
unassigned: "未设置",
|
||||
actions: {
|
||||
menu: "{{name}} 的操作",
|
||||
done: "完成",
|
||||
cancel: "取消",
|
||||
bind: "绑定",
|
||||
rebind: "重新绑定",
|
||||
reset: "重置",
|
||||
clear: "清除",
|
||||
reset: "重置为默认",
|
||||
resetAll: "全部重置",
|
||||
},
|
||||
sections: {
|
||||
@@ -1980,17 +2005,14 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
permissions: {
|
||||
title: "权限",
|
||||
notifications: "通知",
|
||||
microphone: "麦克风",
|
||||
refresh: "刷新",
|
||||
refreshing: "正在刷新...",
|
||||
refreshAccessibility: "刷新桌面端权限",
|
||||
test: "测试",
|
||||
actions: {
|
||||
granted: "已授权",
|
||||
request: "请求",
|
||||
requesting: "正在请求...",
|
||||
busySuffix: "{{label}}...",
|
||||
},
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -3,12 +3,17 @@ import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
getBindingIdForAction,
|
||||
getDefaultKeysForAction,
|
||||
getWorkspaceIndexJumpModifierKey,
|
||||
parseBindingChord,
|
||||
resolveKeyboardShortcut,
|
||||
resolveShortcutKeysForAction,
|
||||
UNASSIGNED_COMBO,
|
||||
type ChordState,
|
||||
type KeyboardShortcutContext,
|
||||
type KeyboardShortcutInput,
|
||||
type ParsedShortcutBinding,
|
||||
type ShortcutOverrides,
|
||||
} from "./keyboard-shortcuts";
|
||||
|
||||
function keyboardInput(overrides: Partial<KeyboardShortcutInput>): KeyboardShortcutInput {
|
||||
@@ -702,6 +707,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
});
|
||||
|
||||
describe("getWorkspaceIndexJumpModifierKey", () => {
|
||||
const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac";
|
||||
|
||||
it("uses Alt on web, regardless of OS", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: false })).toBe("Alt");
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: false })).toBe("Alt");
|
||||
@@ -714,4 +721,263 @@ describe("getWorkspaceIndexJumpModifierKey", () => {
|
||||
it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: true })).toBe("Control");
|
||||
});
|
||||
|
||||
it("derives the modifier from the effective binding, not the platform", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Alt+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBe(
|
||||
"Alt",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses the badges when the jump shortcut is unassigned", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: UNASSIGNED_COMBO });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges when the jump shortcut is rebound to one concrete digit", () => {
|
||||
// Capture can only ever produce a concrete digit, never the 1-9 wildcard,
|
||||
// so the badges would advertise eight workspaces that no longer respond.
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+3" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges for a multi-step chord", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+K Cmd+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges when the combo needs a second modifier", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+Shift+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function parseUnknownBindingChord() {
|
||||
return parseBindingChord("Ctrl+Nonsense");
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective bindings with one help row rewritten to ship without a default
|
||||
* combo, standing in for a binding authored as `combo: ""`. `help.keys` is left
|
||||
* as authored so the empty parsed chord is the only signal.
|
||||
*/
|
||||
function withoutDefaultCombo(helpId: string): ParsedShortcutBinding[] {
|
||||
const bindings: ParsedShortcutBinding[] = [];
|
||||
for (const binding of buildEffectiveBindings({})) {
|
||||
if (binding.help?.id === helpId) {
|
||||
bindings.push(Object.assign({}, binding, { combo: "", parsedChord: [] }));
|
||||
} else {
|
||||
bindings.push(binding);
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
describe("unassigned shortcuts", () => {
|
||||
const TAB_NEW_BINDING = "workspace-tab-new-ctrl-t-non-mac";
|
||||
const desktopNonMac = { isMac: false, isDesktop: true };
|
||||
|
||||
function findRow(sections: ReturnType<typeof buildKeyboardShortcutHelpSections>, id: string) {
|
||||
for (const section of sections) {
|
||||
const row = section.rows.find((candidate) => candidate.id === id);
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("parseBindingChord", () => {
|
||||
it("yields an empty chord for a binding that ships without a default combo", () => {
|
||||
expect(parseBindingChord("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a normal combo into one step", () => {
|
||||
expect(parseBindingChord("Ctrl+T")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses a chord into one step per combo", () => {
|
||||
expect(parseBindingChord("Ctrl+W S")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("still throws on an unparseable combo", () => {
|
||||
expect(parseUnknownBindingChord).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matching", () => {
|
||||
it("stops matching a shortcut the user unassigned", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves other shortcuts firing when one is unassigned", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true, shiftKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.terminal.new");
|
||||
});
|
||||
|
||||
it("restores the default when the override is removed", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.tab.new");
|
||||
});
|
||||
|
||||
it("treats a stored empty combo as unassigned too", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: "" });
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps rebinding to a real combo working", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: "Ctrl+Y" });
|
||||
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event: { key: "y", code: "KeyY", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
}).match?.action,
|
||||
).toBe("workspace.tab.new");
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
}).match,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the default for a non-string stored value", () => {
|
||||
// Storage is unvalidated JSON, so a corrupt value must not throw.
|
||||
const overrides = { [TAB_NEW_BINDING]: 42 } as unknown as ShortcutOverrides;
|
||||
const bindings = buildEffectiveBindings(overrides);
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.tab.new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShortcutKeysForAction", () => {
|
||||
it("returns the default keys when there is no override", () => {
|
||||
expect(resolveShortcutKeysForAction("workspace-tab-new", {}, desktopNonMac)).toEqual([
|
||||
["mod", "T"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when the user unassigned the shortcut", () => {
|
||||
expect(
|
||||
resolveShortcutKeysForAction(
|
||||
"workspace-tab-new",
|
||||
{ [TAB_NEW_BINDING]: UNASSIGNED_COMBO },
|
||||
desktopNonMac,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the override keys when the shortcut is rebound", () => {
|
||||
expect(
|
||||
resolveShortcutKeysForAction(
|
||||
"workspace-tab-new",
|
||||
{ [TAB_NEW_BINDING]: "Ctrl+Y" },
|
||||
desktopNonMac,
|
||||
),
|
||||
).toEqual([["ctrl", "Y"]]);
|
||||
});
|
||||
|
||||
it("falls back to the default keys for an unparseable override", () => {
|
||||
// Matching falls back to the default here, so the display has to as well
|
||||
// or it would advertise keys that do nothing.
|
||||
expect(
|
||||
resolveShortcutKeysForAction(
|
||||
"workspace-tab-new",
|
||||
{ [TAB_NEW_BINDING]: "Ctrl+Nonsense" },
|
||||
desktopNonMac,
|
||||
),
|
||||
).toEqual([["mod", "T"]]);
|
||||
});
|
||||
|
||||
it("falls back to the default keys for a non-string stored value", () => {
|
||||
const overrides = { [TAB_NEW_BINDING]: 42 } as unknown as ShortcutOverrides;
|
||||
expect(resolveShortcutKeysForAction("workspace-tab-new", overrides, desktopNonMac)).toEqual([
|
||||
["mod", "T"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for an action with no binding on this platform", () => {
|
||||
expect(resolveShortcutKeysForAction("message-input-send", {}, desktopNonMac)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("help rows", () => {
|
||||
it("lists no keys for an unassigned shortcut", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the row so the shortcut stays rebindable", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")).not.toBeNull();
|
||||
expect(getBindingIdForAction("workspace-tab-new", desktopNonMac)).toBe(TAB_NEW_BINDING);
|
||||
});
|
||||
|
||||
it("still lists the default keys when nothing is overridden", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual(["mod", "T"]);
|
||||
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac)).toEqual(["mod", "T"]);
|
||||
});
|
||||
|
||||
// A binding authored with `combo: ""` has no default. The settings row uses
|
||||
// this to hide Reset, which would otherwise be a dead button landing on the
|
||||
// same "Not set" state Clear already produced. `help.keys` stays populated
|
||||
// on purpose: it is hand-authored, so the empty parsed chord alone has to be
|
||||
// what marks the binding default-less.
|
||||
it("reports no default keys for a binding that ships without a combo", () => {
|
||||
const bindings = withoutDefaultCombo("workspace-tab-new");
|
||||
|
||||
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac, bindings)).toBeNull();
|
||||
});
|
||||
|
||||
it("lists no help keys for a binding that ships without a combo", () => {
|
||||
const bindings = withoutDefaultCombo("workspace-tab-new");
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user