Compare commits

..
176 changed files with 5010 additions and 3686 deletions
+4 -16
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0 - 2026-10-19
## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
@@ -29,10 +29,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
@@ -47,7 +46,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
### Fixed
@@ -61,8 +59,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
@@ -72,7 +68,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
@@ -104,13 +99,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
@@ -167,10 +158,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
## 0.11.4 - 2026-10-15
+1
View File
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
+3
View File
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
Your code never leaves your machine. Paseo is local-first.
Daemon-owned outbound connections and routed API scope ownership are described in
[Connected hosts](connected-hosts.md).
## System overview
```
+55
View File
@@ -0,0 +1,55 @@
# Connected hosts
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
Connect A to B in A's settings under **Hosts connected to this daemon**, through `paseo --host A host`,
or through `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
`registrationId` created before B is reachable.
## Ownership and authority
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
target description. Replacement inputs never come back through a read API. Keep this file private
when collecting diagnostics or backing up daemon state.
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
including a user's disabled state. Changed input requires an explicit update. Display labels and
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
Disabling retains the opt-out.
Each routed API owns a separate session. A derives its identity from the admitted origin session,
producer, and logical SDK scope. A replaces forwarded identity and grants, and B intersects those
grants with the authenticated link's authority. Reconnect reads current authority. Changing caller
permissions closes existing routes. A routed API cannot select a second hop.
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
The optional `server_info.sourceId` reports this producer identity for source-owner controls; it does not describe the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Connections and recovery
The shared Node connector owns the target grammar and each connection attempt, including a fresh
SSH process. SSH reaches an already running daemon. The daemon keeps unreachable registrations and
retries them. Identity is pinned when discovered. Self and duplicate registrations remain inspectable;
a changed destination requires explicit identity replacement.
Both daemons must advertise `connectedHosts`. A checks B's capability and identity before forwarding
session operations. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
through the usual owned-subscription contract; requests made while offline fail. Losing a response
returns `outcome_unknown` with the destination ID. Inspect the destination before retrying a mutation.
No mutation is automatically replayed.
Host links cap saved registrations, simultaneous routes, frames, per-socket output, and shared queued
bytes. Slow consumers lose their route with a resource-limit error instead of retaining unbounded
output. Routed sessions release server resources on disconnect; subscription demand is rebuilt by
its owning client on reconnect.
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
[CLI reference](../public-docs/cli.md#connected-hosts), and
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
+4
View File
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
see [Connected hosts](connected-hosts.md#ownership-and-authority).
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
+9 -27
View File
@@ -240,9 +240,8 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -333,7 +332,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
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.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -401,25 +400,6 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -511,13 +491,15 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
-8
View File
@@ -74,14 +74,6 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
Workspace setup details negotiate `workspace_setup_observation` with
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
the legacy session event category. The setup panel owns a workspace-scoped observation while
active and visible; directory metadata signals failures without requesting output. Older clients
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
requires a host update to view details. Subscription-capable older hosts honor that absence of
demand; broadcast-era hosts require an update before wire silence is guaranteed.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
+29 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37736,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37980,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38876,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38905,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38922,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38945,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38972,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39190,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40120,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41728,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,115 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
@@ -0,0 +1,66 @@
import { test } from "../support/fixtures";
import {
openRestartableConnectedHosts,
beginConnectedHostDraft,
reconnectManagingHost,
expectConnectedHostDraftRetained,
saveConnectedHost,
openConnectedHosts,
openCompactConnectedHosts,
addConnectedHost,
expectConnectionError,
correctConnection,
inspectConnectedHost,
expectSavedHostDetails,
disableConnectedHost,
enableConnectedHost,
editConnectedHost,
removeConnectedHost,
} from "../support/helpers/connected-hosts";
test("invalid input stays editable; unreachable hosts remain manageable", async ({
page,
}, testInfo) => {
await openConnectedHosts(page);
await addConnectedHost(page, "ssh://-invalid", "Build box");
await expectConnectionError(page);
await correctConnection(page, "tcp://127.0.0.1:1?password=private-secret");
await inspectConnectedHost(page, "Build box");
await expectSavedHostDetails(page);
await page.screenshot({ path: testInfo.outputPath("connected-host-details.png") });
await editConnectedHost(page);
await disableConnectedHost(page);
await enableConnectedHost(page);
await removeConnectedHost(page, "Renamed box");
});
test.describe("compact connected hosts", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("saved hosts can be inspected and disabled on a phone layout", async ({
page,
}, testInfo) => {
await openCompactConnectedHosts(page);
await addConnectedHost(page, "tcp://127.0.0.1:1", "Compact host");
await inspectConnectedHost(page, "Compact host");
await expectSavedHostDetails(page);
await page.screenshot({
path: testInfo.outputPath("connected-host-compact.png"),
fullPage: true,
});
await disableConnectedHost(page);
await removeConnectedHost(page, "Compact host");
});
});
test("connection loss retains the unsaved name and credential input", async ({ page }) => {
const host = await openRestartableConnectedHosts(page);
try {
await beginConnectedHostDraft(page);
await reconnectManagingHost(host);
await expectConnectedHostDraftRetained(page);
await saveConnectedHost(page);
await inspectConnectedHost(page, "Retained draft");
} finally {
await host.close();
}
});
@@ -1,138 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -28,7 +28,6 @@ import {
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
observeSetupPanelTraffic,
} from "../support/helpers/workspace-setup";
interface WorkspaceScriptStarter {
@@ -117,45 +116,6 @@ test.describe("Workspace setup streaming", () => {
}
});
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-membership-", {
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
});
const traffic = observeSetupPanelTraffic(page);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "setup-membership",
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await traffic.expectNoDemand();
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 1);
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
await clickNewChat(page);
await expectComposerVisible(page);
await traffic.expectReleased(0);
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 2);
await closeSetupTab(page, workspace.id);
await traffic.expectReleased(1);
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
@@ -1,145 +0,0 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
assert(
session && metroUrl && serverId,
"Pass the agent-device session, Metro URL and connected host ID",
);
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
async function waitForFocusedInput() {
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
return measurement;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const targets = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.filter((line) => line.includes(appId));
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
const measurement = await waitForFocusedInput();
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad(
"open",
appId,
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
"--platform",
"android",
"--no-test-ime",
);
ad("wait", "text", "Plugin source", "5000");
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
assert(source, "The connected host must support installing plugins from source");
ad("press", `@${source.ref}`);
ad("type", "qa-source");
const pluginMeasurement = await waitForFocusedInput();
// A FormTextInput draws its border and padding on the native input's parent.
// Measure that visible frame, rather than accepting only the text as visible.
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
assert(focused, "Plugin source must remain focused after typing");
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
assert(frame, "The source input's visible frame must be present in the native hierarchy");
assert(
frame.rect.height > focused.rect.height,
"Measure the surrounding frame, including padding",
);
const frameBottom = frame.rect.y + frame.rect.height;
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
assert(
frameBottom <= pluginKeyboardTop + 1,
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
);
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -0,0 +1,117 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openConnectedHosts(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function addConnectedHost(page: Page, connection: string, label: string) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(connection);
await page.getByRole("textbox", { name: "Host name", exact: true }).fill(label);
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"infra"}');
await saveConnectedHost(page);
}
export async function saveConnectedHost(page: Page) {
await page.getByRole("button", { name: "Save host", exact: true }).click();
}
export async function expectConnectionError(page: Page) {
await expect(page.getByText("Invalid host connection.", { exact: false })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toBeVisible();
}
export async function correctConnection(page: Page, value: string) {
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(value);
await saveConnectedHost(page);
}
export async function inspectConnectedHost(page: Page, label: string) {
await expect(
page.getByText(/^(connecting|offline|error|online|disabled) · (Enabled|Disabled) · User$/),
).toBeVisible();
await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click();
await expect(page.getByText("Connected host details", { exact: true })).toBeVisible();
}
export async function expectSavedHostDetails(page: Page) {
await expect(page.getByText('{"team":"infra"}', { exact: true })).toBeVisible();
await expect(page.getByText("Never", { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
export async function disableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByText(/disabled · Disabled/)).toBeVisible();
}
export async function enableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByRole("button", { name: "Disable", exact: true })).toBeVisible();
}
export async function removeConnectedHost(page: Page, label: string) {
await inspectConnectedHost(page, label);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove host", exact: true }).click();
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function openCompactConnectedHosts(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page, new URL(page.url()).pathname);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import { addDirectHostFromSettings, selectSettingsHost } from "./settings";
export async function openRestartableConnectedHosts(page: Page): Promise<IsolatedHostDaemon> {
const host = await startIsolatedHostDaemon("srv_connected_host_draft");
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: host.port });
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
return host;
}
export async function beginConnectedHostDraft(page: Page) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Retained draft");
await page
.getByRole("textbox", { name: "Connection string", exact: true })
.fill("tcp://127.0.0.1:1?password=draft-secret");
}
export async function reconnectManagingHost(host: IsolatedHostDaemon) {
await host.restart();
}
export async function expectConnectedHostDraftRetained(page: Page) {
await expect(page.getByRole("textbox", { name: "Host name", exact: true })).toHaveValue(
"Retained draft",
{ timeout: 30000 },
);
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toHaveValue(
"tcp://127.0.0.1:1?password=draft-secret",
);
}
export async function editConnectedHost(page: Page) {
await page.getByRole("switch", { name: "Replace destination identity", exact: true }).click();
await saveConnectedHost(page);
await expect(
page.getByText("Identity replacement requires a replacement connection", { exact: true }),
).toBeVisible();
await page
.getByRole("textbox", { name: "Replacement connection", exact: true })
.fill("tcp://127.0.0.1:1?password=replacement-secret");
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Renamed box");
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"tools"}');
await saveConnectedHost(page);
await inspectConnectedHost(page, "Renamed box");
await expect(page.getByText('{"team":"tools"}', { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
@@ -24,7 +24,6 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -35,7 +34,6 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -53,7 +51,6 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
@@ -35,8 +35,6 @@ export type { WorkspaceSetupDaemonClient };
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
clientIdPrefix: "workspace-setup",
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
capabilities: { workspace_setup_observation: false },
});
// Establish demand before a caller can launch setup. Client close releases it.
try {
@@ -328,51 +326,3 @@ export async function waitForWorkspaceSetupProgress(
});
});
}
/** Observe the browser's actual setup membership without requesting any output. */
export function observeSetupPanelTraffic(page: Page) {
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
const releases = new Set<string>();
const requests: string[] = [];
let legacySetupDemand = false;
const outputWorkspaces: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "subscription.release.request")
releases.add(frame.message.subscriptionId);
if (frame.message?.type === "workspace.setup.subscribe.request")
requests.push(frame.message.workspaceId);
if (
frame.message?.type === "session.events.set_subscription.request" &&
frame.message.events.includes("workspace_setup_progress")
)
legacySetupDemand = true;
});
socket.on("framereceived", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "workspace.setup.subscribe.response")
subscriptions.push(frame.message.payload);
if (frame.message?.type === "workspace_setup_progress")
outputWorkspaces.push(frame.message.payload.workspaceId);
});
});
return {
async expectNoDemand() {
expect(requests).toEqual([]);
expect(legacySetupDemand).toBe(false);
expect(subscriptions).toEqual([]);
expect(outputWorkspaces).toEqual([]);
},
async expectSubscribed(workspaceId: string, count: number) {
await expect.poll(() => subscriptions.length).toBe(count);
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
expect(requests).toEqual(Array(count).fill(workspaceId));
},
async expectReleased(index: number) {
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,4 +1,4 @@
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,7 +581,9 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -41,7 +41,7 @@ import {
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
@@ -188,7 +188,6 @@ export function useWorkspaceCommandCenterActions(): void {
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
setupStatus: workspace.setupStatus ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
@@ -196,10 +195,12 @@ export function useWorkspaceCommandCenterActions(): void {
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation");
const canShowSetup = shouldShowWorkspaceSetup(
fields?.setupStatus ?? null,
supportsSetupObservation,
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
@@ -1,15 +1,5 @@
import { describe, expect, it } from "vitest";
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
import { resolveFilePanel } from "./resolve";
function filePreviewRenderKind(path: string) {
const { panel } = resolveFilePanel({
file: { path, kind: "text", size: 100, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
});
return panel ? panel.id.slice("builtin:".length) : null;
}
import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
@@ -48,39 +38,3 @@ describe("filePreviewRenderKind", () => {
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
describe("built-in file panels", () => {
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
const resolution = resolveFilePanel({
file: {
path: `/capture.${extension.toUpperCase()}`,
kind: "image",
size: 512,
hasBytes: true,
},
candidates: builtinFilePanels,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a new preview size limit on built-in text panels", () => {
expect(
resolveFilePanel({
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel?.id,
).toBe("builtin:markdown");
});
});
@@ -0,0 +1,17 @@
export type FilePreviewRenderKind = "markdown" | "html";
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}
function isRenderedHtmlFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
}
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
if (isRenderedMarkdownFile(filePath)) return "markdown";
if (isRenderedHtmlFile(filePath)) return "html";
return null;
}
@@ -1,14 +1,10 @@
import { useMemo, type ReactNode } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
interface PageLayoutProps {
title?: string;
@@ -46,13 +42,7 @@ export function PageLayout({
) : (
<MenuHeader borderless />
)}
<PageScrollView
// Leave room for field borders and padding around the focused native input.
{...(Platform.OS === "android" ? { bottomOffset: SPACING[4] } : {})}
style={styles.scroll}
contentContainerStyle={scrollContentStyle}
testID={testID}
>
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<View style={styles.content}>
{showTitle ? (
<View style={styles.titleRow} testID={titleTestID}>
@@ -64,7 +54,7 @@ export function PageLayout({
) : null}
{children}
</View>
</PageScrollView>
</ScrollView>
</View>
);
}
+315
View File
@@ -0,0 +1,315 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createPaseoApi, type HostSummary, type HostDirectory } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
SettingsCard,
SettingsRow,
SettingsInput,
SettingsSwitch,
SettingsSection,
} from "@/components/settings";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { ConnectedHostsModel, type Editor } from "./model";
export function ConnectedHostsSection({ serverId }: { serverId: string }) {
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
return (
<SettingsSection title="Hosts connected to this daemon">
{client ? (
<ConnectedHosts key={serverId} client={client} connected={connected} />
) : (
<SettingsCard>
<SettingsRow label="Connect to this daemon to manage its hosts" />
</SettingsCard>
)}
</SettingsSection>
);
}
function useModel(client: DaemonClient, connected: boolean) {
const [model] = useState(() => new ConnectedHostsModel(createPaseoApi(client)));
useEffect(() => {
model.setConnected(connected);
}, [model, connected]);
useEffect(() => model.close, [model]);
return model;
}
function ConnectedHosts({ client, connected }: { client: DaemonClient; connected: boolean }) {
const model = useModel(client, connected);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const info = client.getLastServerInfoMessage();
const canManage = info?.permissions?.includes("tunnel.manage") === true;
const directory = state.load.status === "loaded" ? state.load.directory : null;
const editor = state.editor;
return (
<View style={styles.section}>
{state.load.status === "loading" ? (
<SettingsCard>
<SettingsRow label="Loading connected hosts…" />
</SettingsCard>
) : null}
{state.load.status === "error" ? (
<SettingsCard>
<SettingsRow label="Could not load connected hosts" error={state.load.error}>
<Button variant="outline" size="sm" onPress={model.start}>
Retry
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{directory ? (
<HostList
directory={directory}
model={model}
canManage={canManage}
pending={state.pending !== null}
/>
) : null}
{directory && canManage && !editor ? (
<Button variant="outline" size="sm" disabled={state.pending !== null} onPress={model.add}>
Add host
</Button>
) : null}
{editor ? (
<HostEditor
key={editor.key}
editor={editor}
directory={directory}
model={model}
canManage={canManage}
manageSources={info?.permissions?.includes("access.manage") === true}
sourceId={info?.sourceId}
pending={state.pending !== null}
/>
) : null}
{state.error ? (
<SettingsCard>
<SettingsRow label="Host change failed" error={state.error}>
<Button variant="ghost" size="sm" onPress={model.dismissError}>
Dismiss
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{state.pending ? (
<Text accessibilityLiveRegion="polite" style={settingsStyles.rowHint}>
Saving host change…
</Text>
) : null}
</View>
);
}
function HostList({
directory,
model,
canManage,
pending,
}: {
directory: HostDirectory;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
return (
<SettingsCard>
{directory.hosts.length === 0 ? (
<SettingsRow label="No hosts connected to this daemon" />
) : (
directory.hosts.map((host) => (
<HostRow
key={host.registrationId}
host={host}
model={model}
canManage={canManage}
pending={pending}
/>
))
)}
</SettingsCard>
);
}
function HostRow({
host,
model,
canManage,
pending,
}: {
host: HostSummary;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
const edit = useCallback(() => model.edit(host), [host, model]);
const toggle = useCallback(() => void model.setEnabled(host), [host, model]);
return (
<SettingsRow
label={host.label ?? host.serverId ?? host.registrationId}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${sourceLabel(host.source.id)}`}
>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={pending}
onPress={edit}
accessibilityLabel={`Details for ${host.label ?? host.registrationId}`}
>
Details
</Button>
{canManage ? (
<Button variant="outline" size="sm" disabled={pending} onPress={toggle}>
{host.enabled ? "Disable" : "Enable"}
</Button>
) : null}
</View>
</SettingsRow>
);
}
function HostDetails({ host }: { host: HostSummary }) {
return (
<SettingsCard>
<SettingsRow label="Registration" hint={host.registrationId} />
<SettingsRow label="Server ID" hint={host.serverId ?? "Not discovered"} />
<SettingsRow label="Connection" hint={host.targetDescription} />
<SettingsRow label="Metadata" hint={JSON.stringify(host.metadata)} />
<SettingsRow label="Status" hint={host.status} />
<SettingsRow label="Source" hint={sourceLabel(host.source.id)} />
<SettingsRow label="Source identity" hint={host.source.id} />
<SettingsRow label="Source key" hint={host.source.key} />
<SettingsRow label="Last online" hint={host.lastOnlineAt ?? "Never"} />
<SettingsRow label="Next retry" hint={host.nextRetryAt ?? "None scheduled"} />
{host.error ? <SettingsRow label={host.error.code} error={host.error.message} /> : null}
</SettingsCard>
);
}
function HostEditor({
editor,
directory,
model,
canManage,
manageSources,
sourceId,
pending,
}: {
editor: Editor;
directory: HostDirectory | null;
model: ConnectedHostsModel;
canManage: boolean;
manageSources: boolean;
sourceId: string | undefined;
pending: boolean;
}) {
const compact = useIsCompactFormFactor();
const detail =
directory?.hosts.find((host) => host.registrationId === editor.host?.registrationId) ??
editor.host;
const editSource = !detail || manageSources || detail.source.id === sourceId;
const remove = useCallback(async () => {
if (!detail) return;
const confirmed = await confirmDialog({
title: "Remove connected host?",
message:
detail.source.id === "owner"
? "This removes the saved connection. The host's agents and workspaces keep running."
: `Managed by ${detail.source.id}. Its source can add it again. Disable the host to keep access off.`,
confirmLabel: "Remove",
destructive: true,
});
if (confirmed) await model.remove(detail);
}, [detail, model]);
return (
<View style={styles.section}>
<Text style={settingsStyles.rowTitle}>{detail ? "Connected host details" : "Add host"}</Text>
{detail ? <HostDetails host={detail} /> : null}
{canManage ? (
<SettingsCard>
<SettingsInput
label="Host name"
initialValue={editor.label}
onChangeText={model.setLabel}
disabled={pending}
/>
{editSource ? (
<SettingsInput
label={detail ? "Replacement connection" : "Connection string"}
initialValue={editor.connection}
onChangeText={model.setConnection}
secureTextEntry
disabled={pending}
/>
) : null}
{detail && editSource ? (
<SettingsSwitch
label="Replace destination identity"
value={editor.replaceIdentity}
onValueChange={model.setReplaceIdentity}
disabled={pending}
/>
) : null}
</SettingsCard>
) : null}
{canManage && editSource ? (
<Field label="Metadata">
<FormTextInput
accessibilityLabel="Metadata"
initialValue={editor.metadata}
onChangeText={model.setMetadata}
multiline
editable={!pending}
size={compact ? "md" : "sm"}
/>
</Field>
) : null}
<View style={styles.actions}>
{canManage ? (
<Button
variant="default"
size="sm"
loading={pending}
disabled={pending}
onPress={model.save}
>
Save host
</Button>
) : null}
<Button variant="ghost" size="sm" disabled={pending} onPress={model.cancel}>
Cancel
</Button>
{canManage && detail && editSource ? (
<Button variant="outline" size="sm" disabled={pending} onPress={remove}>
Remove host
</Button>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: { gap: theme.spacing[3] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
}));
function sourceLabel(sourceId: string): string {
if (sourceId === "owner") return "User";
if (sourceId.startsWith("plugin:")) return `Plugin: ${sourceId.slice("plugin:".length)}`;
if (sourceId.startsWith("route:")) {
const components = sourceId.split(":");
let producer: string;
try {
producer = decodeURIComponent(components[3] ?? "");
} catch {
return "User (via host)";
}
return producer.startsWith("plugin:")
? `Plugin: ${producer.slice("plugin:".length)} (via host)`
: "User (via host)";
}
return "User";
}
+192
View File
@@ -0,0 +1,192 @@
import type {
PaseoApi,
HostSummary,
HostUpdate,
HostDirectory,
OwnedSubscription,
} from "@getpaseo/client";
export interface Editor {
key: number;
host: HostSummary | null;
connection: string;
label: string;
metadata: string;
replaceIdentity: boolean;
}
type Load =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; directory: HostDirectory };
interface State {
load: Load;
editor: Editor | null;
pending: string | null;
error: string | null;
}
/** Owns fetch/observation, form sequencing and mutation state outside React. */
export class ConnectedHostsModel {
private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null };
private readonly listeners = new Set<() => void>();
private observation?: OwnedSubscription<HostDirectory>;
private generation = 0;
private editorKey = 0;
private connected = false;
setConnected = (connected: boolean) => {
this.connected = connected;
if (connected && !this.observation) this.start();
else if (!connected)
this.publish({
load: {
status: "error",
error:
"Connection to this daemon was lost. Reconnect to continue; your edits are retained.",
},
});
};
constructor(private readonly api: PaseoApi) {}
getState = () => this.state;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private publish(patch: Partial<State>) {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) listener();
}
start = () => {
if (!this.connected) {
this.publish({
load: { status: "error", error: "Connect to this daemon to load its hosts." },
});
return;
}
const generation = ++this.generation;
void this.observation?.release();
this.publish({ load: { status: "loading" } });
try {
const observation = this.api.hosts.observe();
this.observation = observation;
observation.subscribe({
snapshot: (directory) => {
if (generation === this.generation)
this.publish({ load: { status: "loaded", directory } });
},
update: (message) => {
if (generation === this.generation && message.type === "hosts.changed")
this.publish({ load: { status: "loaded", directory: message.payload.directory } });
},
error: (error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
},
});
void observation.ready.catch((error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
});
} catch (error) {
this.publish({ load: { status: "error", error: describe(error) } });
}
};
close = () => {
this.generation++;
void this.observation?.release();
void this.api.dispose();
this.listeners.clear();
};
edit = (host: HostSummary | null) => {
if (this.state.pending) return;
this.publish({
error: null,
editor: {
key: ++this.editorKey,
host,
connection: "",
label: host?.label ?? "",
metadata: JSON.stringify(host?.metadata ?? {}, null, 2),
replaceIdentity: false,
},
});
};
add = () => this.edit(null);
setLabel = (label: string) => this.set({ label });
setConnection = (connection: string) => this.set({ connection });
setMetadata = (metadata: string) => this.set({ metadata });
setReplaceIdentity = (replaceIdentity: boolean) => this.set({ replaceIdentity });
set = (patch: Partial<Omit<Editor, "host" | "key">>) => {
if (this.state.editor) this.publish({ editor: { ...this.state.editor, ...patch } });
};
cancel = () => {
if (!this.state.pending) this.publish({ editor: null, error: null });
};
dismissError = () => this.publish({ error: null });
private async mutate(id: string, operation: () => Promise<unknown>): Promise<void> {
if (this.state.pending) return;
this.publish({ pending: id, error: null });
try {
await operation();
this.publish({
load: { status: "loaded", directory: await this.api.hosts.list() },
editor: null,
});
} catch (error) {
this.publish({ error: describe(error) });
} finally {
this.publish({ pending: null });
}
}
save = () =>
this.mutate("save", async () => {
const editor = this.state.editor;
if (!editor) return;
let metadata: Record<string, string>;
try {
const value: unknown = JSON.parse(editor.metadata);
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.values(value).some((entry) => typeof entry !== "string")
)
throw new Error("invalid");
metadata = value as Record<string, string>;
} catch {
throw new Error("Metadata must be a JSON object with string values.");
}
if (editor.host) {
const patch: HostUpdate = {
label: editor.label.trim() || null,
replaceIdentity: editor.replaceIdentity,
};
if (JSON.stringify(metadata) !== JSON.stringify(editor.host.metadata))
patch.metadata = metadata;
if (editor.connection.trim()) {
patch.connection = editor.connection.trim();
}
await this.api.hosts.update(editor.host.registrationId, patch);
} else
await this.api.hosts.add({
connection: editor.connection.trim(),
label: editor.label.trim() || undefined,
metadata,
});
});
setEnabled = (host: HostSummary) =>
this.mutate(host.registrationId, () =>
this.api.hosts.update(host.registrationId, { enabled: !host.enabled }),
);
remove = (host: HostSummary) =>
this.mutate(host.registrationId, () => this.api.hosts.remove(host.registrationId));
}
function describe(error: unknown): string {
if (
error &&
typeof error === "object" &&
"userMessage" in error &&
typeof error.userMessage === "string"
)
return error.userMessage;
return error instanceof Error ? error.message : "Host operation failed. Retry when connected.";
}
@@ -36,6 +36,7 @@ import {
useSessionStore,
type SessionState,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
@@ -167,6 +168,11 @@ function resolveAgentAttentionNotification(
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
@@ -219,6 +225,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -358,6 +365,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
},
[serverId, upsertWorkspaceSetupProgress],
);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const onStream = (message: SessionOutboundMessage) => {
@@ -494,6 +508,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
"agent_permission_resolved",
"agent.provider_subagents.update",
"checkout_status_update",
"workspace_setup_progress",
"status.server_info",
],
{ notifications: true },
@@ -532,6 +547,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
});
const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubStatus = onFeed("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -690,6 +710,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -711,6 +732,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
updateSessionServerInfo,
toast,
voiceRuntime,
@@ -4,7 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/file-panels";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { withPreviewCsp } from "./html-preview-csp";
import { withPreviewCsp } from "@/file-pane/html-preview-csp";
describe("withPreviewCsp", () => {
it("places the policy before the complete source document", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { htmlPreviewNavigationKind } from "./html-preview-navigation";
import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation";
describe("htmlPreviewNavigationKind", () => {
it("allows the WebView's initial document URLs", () => {
+79 -34
View File
@@ -1,13 +1,20 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { Text, View } from "react-native";
import { ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
@@ -21,6 +28,8 @@ import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
@@ -30,6 +39,7 @@ import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -37,7 +47,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
});
interface FilePreviewBodyProps {
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
@@ -122,7 +131,6 @@ function TooLargeSource({ size }: { size?: number }) {
}
function FilePreviewBody({
panel,
preview,
mode,
isLoading,
@@ -136,6 +144,14 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
// the highlighted source view even for renderable files.
const renderKind =
preview?.kind === "text" && !location.lineStart && mode !== "source"
? filePreviewRenderKind(filePath)
: null;
const previewScrollRef = useRef<RNScrollView>(null);
if (isLoading && !preview) {
return (
@@ -154,21 +170,36 @@ function FilePreviewBody({
);
}
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
if (panel && !showSource) {
return (
<FilePanelPreview
panel={panel}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
imagePreviewUri={imagePreviewUri}
/>
);
}
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
</View>
);
}
if (renderKind === "markdown") {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</RNScrollView>
</View>
);
}
return (
<ReadonlySource
preview={preview}
@@ -179,6 +210,19 @@ function FilePreviewBody({
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
@@ -199,7 +243,6 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
@@ -249,12 +292,7 @@ export function FilePane({
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const isRenderable = isRenderablePreview(preview, location.path);
const editable = isEditableTextFile({
preview,
supportsEditing,
@@ -270,7 +308,6 @@ export function FilePane({
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
@@ -296,6 +333,10 @@ export function FilePane({
);
}
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
@@ -309,7 +350,6 @@ function isEditableTextFile(input: {
}
function FilePanePresentation({
panel,
serverId,
workspaceRoot,
client,
@@ -332,7 +372,6 @@ function FilePanePresentation({
navigationRevision,
imagePreviewUri,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
@@ -368,7 +407,6 @@ function FilePanePresentation({
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
panel={panel}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
@@ -421,7 +459,6 @@ function FilePanePresentation({
/>
) : null}
<FilePreviewBody
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
@@ -438,7 +475,6 @@ function FilePanePresentation({
}
function EditableFilePane({
panel,
serverId,
workspaceRoot,
client,
@@ -456,7 +492,6 @@ function EditableFilePane({
location,
navigationRevision,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
@@ -604,7 +639,6 @@ function EditableFilePane({
/>
) : (
<FilePreviewBody
panel={panel}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
@@ -674,4 +708,15 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
},
previewContent: {
flex: 1,
minHeight: 0,
},
previewCodeScrollContent: {
padding: theme.spacing[4],
},
}));
-92
View File
@@ -1,92 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import type { builtinFilePanels } from "./catalog";
interface FilePanelPreviewProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
imagePreviewUri: string | null;
}
function MarkdownPanel(props: FilePanelPreviewProps) {
return (
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
<FileMarkdownPreview
source={props.content}
filePath={props.filePath}
workspaceRoot={props.workspaceRoot}
client={props.client}
serverId={props.serverId}
/>
</ScrollView>
</View>
);
}
function HtmlPanel({ content }: FilePanelPreviewProps) {
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
</View>
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
const { t } = useTranslation();
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
};
export function FilePanelPreview({
panel,
...props
}: FilePanelPreviewProps & {
panel: (typeof builtinFilePanels)[number];
}) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
-28
View File
@@ -1,28 +0,0 @@
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
const markdownPanel = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export const builtinFilePanels = [
markdownPanel,
{
id: "builtin:html",
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:image",
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
},
] as const satisfies readonly FilePanelCandidate[];
export function isRenderedMarkdownFile(path: string): boolean {
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
}
-23
View File
@@ -1,23 +0,0 @@
import type { ExplorerFile } from "@/stores/session-store";
import { builtinFilePanels } from "./catalog";
import { resolveFilePanel } from "./resolve";
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
export { FilePanelPreview } from "./builtin";
export type FilePanel = (typeof builtinFilePanels)[number];
export function resolveFilePreviewPanel(input: {
preview: ExplorerFile | null;
path: string;
associations: Readonly<Record<string, string>>;
}) {
const { preview, path, associations } = input;
if (!preview) return null;
return resolveFilePanel({
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
candidates: builtinFilePanels,
associations,
}).panel;
}
@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
const markdown: FilePanelCandidate = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
function resolve(
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
) {
return resolveFilePanel({
file,
candidates: [markdown, alternative],
associations: {},
...overrides,
});
}
describe("resolveFilePanel", () => {
it("uses the associated candidate before catalog order", () => {
expect(resolve({ associations: { "ext:md": alternative.id } })).toEqual({
panel: alternative,
candidates: [markdown, alternative],
selectorKey: "ext:md",
});
});
it("ignores an absent association without deleting it", () => {
const associations = { "ext:md": "plugin:absent/markdown" };
expect(resolve({ associations }).panel).toBe(markdown);
expect(associations).toEqual({ "ext:md": "plugin:absent/markdown" });
});
it("uses a single candidate and preserves catalog order for multiple candidates", () => {
expect(resolve({ candidates: [alternative] }).panel).toBe(alternative);
expect(resolve().panel).toBe(markdown);
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
});
it("returns the floor when no selector matches", () => {
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
panel: null,
candidates: [],
selectorKey: "ext:ts",
});
});
it("filters text panels for binary files even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
).toBeNull();
});
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
});
it("includes the maxBytes boundary and skips oversized associated panels", () => {
const limited = { ...alternative, maxBytes: 100 };
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
expect(
resolve({
file: { ...file, size: 101 },
candidates: [limited, markdown],
associations: { "ext:md": limited.id },
}).panel,
).toBe(markdown);
});
it("uses exact basename keys when an eligible candidate matches by filename", () => {
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
expect(
resolve({ candidates: [markdown, named], associations: { "name:README.MD": named.id } }),
).toEqual({
panel: named,
candidates: [markdown, named],
selectorKey: "name:README.MD",
});
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
});
it("uses extension keys when filename candidates were filtered out", () => {
const named = {
...alternative,
maxBytes: 1,
files: { extensions: [], filenames: ["README.MD"] },
};
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
});
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
"matches an extensionless exact filename: %s",
(path) => {
const named = { ...alternative, files: { extensions: [], filenames: ["Dockerfile"] } };
expect(resolve({ file: { ...file, path }, candidates: [named] })).toEqual({
panel: named,
candidates: [named],
selectorKey: "name:Dockerfile",
});
},
);
it("normalizes extension selectors and keeps extensionless files distinct", () => {
const upper = { ...alternative, files: { extensions: ["MD"] } };
expect(resolve({ candidates: [upper] }).panel).toBe(upper);
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
});
});
-63
View File
@@ -1,63 +0,0 @@
export interface FilePanelFile {
path: string;
kind: "text" | "image" | "binary";
size: number;
hasBytes: boolean;
}
export interface FilePanelSelector {
extensions: readonly string[];
filenames?: readonly string[];
}
export interface FilePanelCandidate {
id: string;
files: FilePanelSelector;
document: "text" | "bytes";
maxBytes: number;
}
export interface FilePanelResolution<Candidate> {
panel: Candidate | null;
candidates: readonly Candidate[];
selectorKey: string;
}
export function filePanelExtension(path: string): string {
const name = filePanelBasename(path);
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
}
function filePanelBasename(path: string): string {
return path.trim().split(/[\\/]/).pop() ?? "";
}
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
const extension = filePanelExtension(file.path);
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
}
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
file: FilePanelFile;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
);
});
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) =>
candidate.files.filenames?.includes(filename),
);
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
const association = associations[selectorKey];
const panel =
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
return { panel, candidates, selectorKey };
}
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { GitAction, GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -129,10 +129,14 @@ export function GitActionsSplitButton({
[theme.colors.surface2],
);
const menuOnlyActions = [
...(gitActions.primary ? [gitActions.primary] : []),
...actionsWithoutPrimary(gitActions),
];
const menuOnlyActions = useMemo(
() => [
...(gitActions.primary ? [gitActions.primary] : []),
...gitActions.secondary,
...gitActions.menu,
],
[gitActions.menu, gitActions.primary, gitActions.secondary],
);
if (menuOnly) {
if (menuOnlyActions.length === 0) {
+1 -54
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -190,59 +190,6 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("drops a promoted remote action from the remaining menu actions", () => {
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
expect(pull.primary?.id).toBe("pull");
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
"push",
"pull-and-push",
"archive-workspace",
]);
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
expect(push.primary?.id).toBe("push");
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
"pull",
"pull-and-push",
"archive-workspace",
]);
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"archive-workspace",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
-12
View File
@@ -312,18 +312,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
};
}
/**
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
* has to drop the promoted one by id or it renders it twice.
*/
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
const primaryActionId = gitActions.primary?.id;
return [...gitActions.secondary, ...gitActions.menu].filter(
(action) => action.id !== primaryActionId,
);
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
@@ -44,33 +44,6 @@ function makeDeps(
}
describe("loadAppSettingsFromStorage", () => {
it("persists file panel associations, including panels absent from this host", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
const filePanelAssociations = {
"ext:md": "builtin:markdown",
"name:Dockerfile": "plugin:absent/container",
};
await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } });
expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual(
filePanelAssociations,
);
});
it.each([undefined, null, [], "invalid", { "ext:md": 42 }])(
"defaults malformed file panel associations without discarding other settings: %j",
async (filePanelAssociations) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }),
}),
});
const settings = await loadAppSettingsFromStorage(deps);
expect(settings.filePanelAssociations).toEqual({});
expect(settings.vimKeybindings).toBe(true);
},
);
it("preserves a persisted steer send behavior", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -104,7 +104,6 @@ export interface AppSettings {
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
pullRequestOpenLocation: PullRequestOpenLocation;
@@ -161,7 +160,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
vimKeybindings: false,
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
};
@@ -263,7 +261,6 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
explorerFiles: z.boolean().catch(false),
-2
View File
@@ -538,7 +538,6 @@ export const ar: TranslationResources = {
},
},
setup: {
updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل",
descriptor: {
label: "يثبت",
completed: "اكتمل الإعداد",
@@ -2334,7 +2333,6 @@ export const ar: TranslationResources = {
searchPlaceholder: "البحث في الاختصارات",
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
capturePrompt: "اضغط على الاختصار...",
captureIndexPrompt: "اختم برقم من 1 إلى 9.",
unassigned: "غير معين",
actions: {
menu: "إجراءات {{name}}",
-2
View File
@@ -535,7 +535,6 @@ export const en = {
},
},
setup: {
updateHost: "Update the host to view workspace setup details",
descriptor: {
label: "Setup",
completed: "Setup completed",
@@ -2457,7 +2456,6 @@ export const en = {
searchPlaceholder: "Search shortcuts",
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
capturePrompt: "Press shortcut...",
captureIndexPrompt: "End with a number from 1 to 9.",
unassigned: "Not set",
actions: {
menu: "Actions for {{name}}",
-2
View File
@@ -544,7 +544,6 @@ export const es: TranslationResources = {
},
},
setup: {
updateHost: "Actualiza el host para ver los detalles de configuración del espacio de trabajo",
descriptor: {
label: "Configuración",
completed: "Configuración completada",
@@ -2388,7 +2387,6 @@ export const es: TranslationResources = {
searchPlaceholder: "Buscar atajos",
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
capturePrompt: "Presione el acceso directo...",
captureIndexPrompt: "Termina con un número del 1 al 9.",
unassigned: "Sin asignar",
actions: {
menu: "Acciones para {{name}}",
-3
View File
@@ -543,8 +543,6 @@ export const fr: TranslationResources = {
},
},
setup: {
updateHost:
"Mettez à jour l’hôte pour afficher les détails de configuration de l’espace de travail",
descriptor: {
label: "Configuration",
completed: "Configuration terminée",
@@ -2389,7 +2387,6 @@ export const fr: TranslationResources = {
searchPlaceholder: "Rechercher des raccourcis",
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau",
capturePrompt: "Appuyez sur le raccourci…",
captureIndexPrompt: "Terminez par un chiffre de 1 à 9.",
unassigned: "Non attribué",
actions: {
menu: "Actions pour {{name}}",
-2
View File
@@ -544,7 +544,6 @@ export const ja: TranslationResources = {
},
},
setup: {
updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください",
descriptor: {
label: "セットアップ",
completed: "セットアップ完了",
@@ -2355,7 +2354,6 @@ export const ja: TranslationResources = {
searchPlaceholder: "ショートカットを検索",
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
capturePrompt: "ショートカットを押してください...",
captureIndexPrompt: "最後に1〜9の数字を入力してください。",
unassigned: "未設定",
actions: {
menu: "{{name}} のアクション",
-2
View File
@@ -539,7 +539,6 @@ export const ko: TranslationResources = {
},
},
setup: {
updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요",
descriptor: {
label: "설정",
completed: "설정 완료",
@@ -2344,7 +2343,6 @@ export const ko: TranslationResources = {
searchPlaceholder: "검색 단축키",
unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다",
capturePrompt: "단축키를 누르세요...",
captureIndexPrompt: "마지막에 1부터 9까지의 숫자를 입력하세요.",
unassigned: "설정되지 않음",
actions: {
menu: "{{name}} 작업",
-2
View File
@@ -543,7 +543,6 @@ export const ptBR: TranslationResources = {
},
},
setup: {
updateHost: "Atualize o host para ver os detalhes da configuração do espaço de trabalho",
descriptor: {
label: "Configuração",
completed: "Configuração concluída",
@@ -2369,7 +2368,6 @@ export const ptBR: TranslationResources = {
searchPlaceholder: "Pesquisar atalhos",
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
capturePrompt: "Pressione o atalho...",
captureIndexPrompt: "Termine com um número de 1 a 9.",
unassigned: "Não definido",
actions: {
menu: "Ações para {{name}}",
-2
View File
@@ -543,7 +543,6 @@ export const ru: TranslationResources = {
},
},
setup: {
updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства",
descriptor: {
label: "Настройка",
completed: "Настройка завершена",
@@ -2374,7 +2373,6 @@ export const ru: TranslationResources = {
searchPlaceholder: "Поиск сочетаний клавиш",
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
capturePrompt: "Нажмите сочетание клавиш...",
captureIndexPrompt: "Завершите цифрой от 1 до 9.",
unassigned: "Не задано",
actions: {
menu: "Действия для {{name}}",
-2
View File
@@ -538,7 +538,6 @@ export const zhCN: TranslationResources = {
},
},
setup: {
updateHost: "更新主机以查看工作区设置详情",
descriptor: {
label: "Setup",
completed: "Setup 已完成",
@@ -2306,7 +2305,6 @@ export const zhCN: TranslationResources = {
searchPlaceholder: "搜索快捷键",
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
capturePrompt: "按下快捷键...",
captureIndexPrompt: "请以 1 到 9 的数字结尾。",
unassigned: "未设置",
actions: {
menu: "{{name}} 的操作",
@@ -4,9 +4,7 @@ import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
DEFAULT_BINDINGS,
normalizeCapturedShortcutCombo,
isShortcutCaptureComplete,
getBindingIdForAction,
getDefaultKeysForAction,
getWorkspaceIndexJumpModifierKey,
@@ -1392,22 +1390,3 @@ describe("number-group shortcut capture", () => {
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit");
});
});
describe("shortcut capture completion", () => {
const indexBindings = DEFAULT_BINDINGS.filter((binding) => binding.payload?.type === "index");
it.each(indexBindings)("requires a final 1–9 number for $id", ({ id }) => {
for (const combos of [[], ["Ctrl+K"], ["Ctrl+0"], ["Ctrl+Digit", "Ctrl+K"]]) {
expect(isShortcutCaptureComplete(id, combos)).toBe(false);
}
for (let index = 1; index <= 9; index++) {
const combo = normalizeCapturedShortcutCombo(id, `Ctrl+${index}`);
expect(isShortcutCaptureComplete(id, [combo])).toBe(true);
expect(isShortcutCaptureComplete(id, ["Ctrl+K", combo])).toBe(true);
}
});
it("keeps ordinary shortcuts available without a number", () => {
expect(isShortcutCaptureComplete("show-shortcuts", [])).toBe(false);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+K"])).toBe(true);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true);
});
});
@@ -1217,14 +1217,6 @@ export function normalizeCapturedShortcutCombo(bindingId: string, combo: string)
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
}
/** Index navigation needs a final number; earlier combos may be any chord prefix. */
export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean {
const lastCombo = combos.at(-1);
if (!lastCombo) return false;
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo);
}
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
return DEFAULT_BINDINGS.map(function (binding) {
const override = overrides[binding.id];
+47 -60
View File
@@ -1,7 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
@@ -17,9 +17,6 @@ import {
type WorkspaceSetupSnapshot,
} from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useHostFeature } from "@/runtime/host-features";
function useSetupPanelDescriptor(
@@ -27,11 +24,13 @@ function useSetupPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const { t } = useTranslation();
const fields = useWorkspaceFields(context.serverId, target.workspaceId, (workspace) => ({
status: workspace.setupStatus ?? null,
}));
const key = buildWorkspaceTabPersistenceKey({
serverId: context.serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
if (fields?.status === "completed") {
if (snapshot?.status === "completed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.completed"),
@@ -42,7 +41,7 @@ function useSetupPanelDescriptor(
};
}
if (fields?.status === "failed") {
if (snapshot?.status === "failed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.failed"),
@@ -53,7 +52,7 @@ function useSetupPanelDescriptor(
};
}
if (fields?.status === "blocked") {
if (snapshot?.status === "blocked") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.blocked"),
@@ -70,7 +69,7 @@ function useSetupPanelDescriptor(
tooltip: t("workspace.setup.descriptor.workspace"),
titleState: "ready",
icon: SquareTerminal,
statusBucket: fields?.status === "running" ? "running" : null,
statusBucket: snapshot?.status === "running" ? "running" : null,
};
}
@@ -217,34 +216,33 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
}
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string, enabled: boolean) {
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const applySnapshot = useWorkspaceSetupStore((state) => state.applySnapshot);
const [error, setError] = useState<string | null>(null);
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const requestedRef = useRef(false);
useEffect(() => {
if (!client || !enabled) return;
setError(null);
const subscription = client.observeWorkspaceSetup(workspaceId);
const detach = subscription.subscribe({
snapshot: (response) => {
applySnapshot({ serverId, workspaceId, snapshot: response.snapshot });
},
update: (message) => {
if (message.type === "workspace_setup_progress")
applySnapshot({ serverId, workspaceId, snapshot: message.payload });
},
error: (failure) => setError(failure instanceof Error ? failure.message : String(failure)),
});
return () => {
detach();
void subscription
.release()
.catch((failure) => console.warn("Failed to release setup observation", failure));
};
}, [client, enabled, serverId, applySnapshot, workspaceId]);
return { snapshot, error };
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
return;
})
.catch(() => {
// Server may not support this yet — ignore.
});
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
return snapshot;
}
function useExpandedSetupCommands() {
@@ -275,14 +273,7 @@ function SetupPanel() {
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const isActive = useRetainedPanelActive();
const isVisible = useAppActivelyVisible();
const supportsObservation = useHostFeature(serverId, "workspaceSetupObservation");
const { snapshot, error: observationError } = useWorkspaceSetupSnapshot(
serverId,
target.workspaceId,
isActive && isVisible && supportsObservation,
);
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
@@ -315,23 +306,19 @@ function SetupPanel() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
{!supportsObservation && <Alert variant="warning" title={t("workspace.setup.updateHost")} />}
{observationError && <Alert variant="error" title={observationError} />}
{supportsObservation && !observationError && (
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
)}
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
</ScrollView>
);
}
@@ -1,10 +1,6 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import {
PluginAttachmentItemSchema,
type PluginAttachmentItem,
type PluginSerializableIcon,
} from "@getpaseo/plugin";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
import type { UserComposerAttachment } from "@/attachments/types";
export const PluginResourceComposerAttachmentSchema = z.object({
@@ -12,8 +8,7 @@ export const PluginResourceComposerAttachmentSchema = z.object({
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
// Validated when the source registered; a draft keeps the icon its source had then.
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
sourceIcon: z.string().min(1),
item: PluginAttachmentItemSchema,
});
@@ -25,7 +20,7 @@ export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: PluginSerializableIcon;
sourceIcon: string;
}
export function createPluginResourceAttachment(
+14 -14
View File
@@ -2,18 +2,15 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PluginAttachmentItem,
PluginAttachmentSourceContribution,
PluginSerializableIcon,
} from "@getpaseo/plugin";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PluginIconView } from "../icons";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
@@ -22,8 +19,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
@@ -148,12 +145,15 @@ export function usePluginAttachmentPicker(
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => ({
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
})),
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
[sources],
);
if (!active) return { menuItems, picker: null };
+12 -6
View File
@@ -3,14 +3,19 @@ import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { PluginSerializableIcon } from "@getpaseo/plugin";
import { PluginIconView } from "../icons";
import { resolvePluginIcon } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
@@ -34,11 +39,12 @@ export function PluginResourceAttachmentPill({
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
[attachment.sourceIcon],
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
);
return (
<AttachmentPill
@@ -30,9 +30,8 @@ function installation(): InstalledPlugin {
function store() {
return new PluginButtonStore({
parseIcon(icon) {
if (icon !== "Scan") throw new Error("Unknown icon");
return icon;
validateIconName(name) {
if (name !== "Scan") throw new Error("Unknown icon");
},
});
}
+2 -4
View File
@@ -1,6 +1,4 @@
import { parsePluginIcon } from "../icons";
import { resolvePluginIcon } from "../icons";
import { PluginButtonStore } from "./model";
export const pluginButtonStore = new PluginButtonStore({
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
});
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
@@ -1,8 +1,7 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
export interface ButtonValidation {
/** Returns the normalized icon, or throws naming why the icon is unusable. */
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
validateIconName(name: string): void;
}
export function requireButtonId(value: string): string {
@@ -30,6 +29,12 @@ function isComponent(value: unknown): boolean {
);
}
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
if (typeof icon === "string") validation.validateIconName(icon);
else if (!isComponent(icon))
throw new Error("Plugin button icon must be a Lucide name or component");
}
function validateBehavior(
behavior: PluginButtonBehavior,
validation: ButtonValidation,
@@ -52,9 +57,9 @@ function validateBehavior(
ids.add(item.id);
if (item.kind === "separator") return { ...item };
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
if (item.icon !== undefined) validateIcon(item.icon, validation);
return {
...item,
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
title: requireText(item.title, "menu item title"),
visible: optionalBoolean(item.visible, true),
disabled: optionalBoolean(item.disabled, false),
@@ -78,9 +83,9 @@ export function validateButton(
button: PluginButton,
validation: ButtonValidation,
): ResolvedPluginButton {
validateIcon(button.icon, validation);
return {
...button,
icon: validation.parseIcon(button.icon),
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
visible: optionalBoolean(button.visible, true),
+15 -3
View File
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { pluginIconComponent } from "../icons";
import { Icon } from "../icons";
import {
PluginEnvironmentProvider,
PluginPopoverContent,
@@ -79,7 +79,6 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
const color = view.props.theme.colors.foregroundMuted;
const size = view.entry.placement === "composer" ? 14 : 16;
const Icon = pluginIconComponent(icon);
return (
<View
style={styles.icon}
@@ -88,11 +87,24 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Icon {...view.props} size={size} color={color} />
{typeof icon === "string" ? (
<Icon name={icon} size={size} color={color} />
) : (
renderCustomIcon(icon, view, size, color)
)}
</View>
);
}
function renderCustomIcon(
CustomIcon: Exclude<PluginButtonIcon, string>,
view: ButtonView,
size: number,
color: string,
) {
return <CustomIcon {...view.props} size={size} color={color} />;
}
function pressButton(view: ButtonView, path: readonly string[]) {
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
view.toast.error(error instanceof Error ? error.message : String(error));
@@ -1,7 +1,7 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { pluginIconComponent } from "../icons";
import { resolvePluginIcon } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { InstalledPlugin } from "../types";
import { createPluginCapabilities, type PluginNavigation } from "../actions";
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
kind: "action",
title: item.title,
sectionTitle: plugin.id,
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
},
run,
});
-66
View File
@@ -381,72 +381,6 @@ describe("evaluatePluginClientBundle", () => {
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
});
it("registers inline SVG and component icons", () => {
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function ReviewPanel() { return null; }
function BadgeIcon() { return null; }
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
context: "workspace",
Component: ReviewPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: BadgeIcon,
context: "global",
onSelect() {},
});
plugin.addAttachmentSource({
id: "issues",
title: "Issues",
icon: { svg: ${JSON.stringify(svg)} },
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
});
it.each([
[
"workspace panel",
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
"Workspace panel review has an invalid icon: script elements are not allowed",
],
[
"Command Center item",
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
"Command Center item review has an invalid icon: script elements are not allowed",
],
[
"settings screen",
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
"Settings screen review has an invalid icon: script elements are not allowed",
],
[
"attachment source",
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
"Attachment source review has an invalid icon: script elements are not allowed",
],
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
expect(() =>
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
).toThrow(message);
});
it("normalizes and validates workspace panel locations", () => {
const plugin = evaluatePluginClientBundle(
"review",
+15 -10
View File
@@ -33,7 +33,7 @@ import {
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { resolvePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
resolvePluginIcon(contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
settingsScreenIds.delete(screenId),
);
},
@@ -241,10 +241,12 @@ export function runPluginClientBundle(
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
const legacy = {
id: normalizedId,
title: contribution.title.trim(),
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
};
// Shares the header's ids: the sidebar orders both kinds of item under one key.
@@ -260,14 +262,16 @@ export function runPluginClientBundle(
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
return register(
@@ -288,8 +292,9 @@ export function runPluginClientBundle(
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
@@ -300,6 +305,7 @@ export function runPluginClientBundle(
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
return register(
collector.commandCenterItems,
@@ -344,19 +350,18 @@ export function runPluginClientBundle(
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
const icon = parseSerializablePluginIcon(
`Attachment source ${normalizedId}`,
contribution.icon,
);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
return register(
collector.attachmentSources,
+18
View File
@@ -0,0 +1,18 @@
import { Settings } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { Icon } from "./icons";
describe("Icon", () => {
it("renders a host Lucide icon with the requested presentation", () => {
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders nothing for an unknown icon name", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
});
});
-104
View File
@@ -1,104 +0,0 @@
import { Settings } from "lucide-react-native";
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
import { SvgXml } from "react-native-svg";
import { describe, expect, it } from "vitest";
import type { PluginIconProps } from "@getpaseo/plugin";
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const SVG_ICON = { svg: SVG };
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
function draw(node: ReactNode): ReactElement | null {
if (!isValidElement(node)) return null;
const { type } = node;
if (typeof type !== "function" || GLYPHS.has(type)) return node;
return draw((type as (props: unknown) => ReactNode)(node.props));
}
function BadgeIcon({ size, color }: PluginIconProps) {
return <span data-size={size} data-color={color} />;
}
describe("PluginIconView", () => {
it("draws a Lucide name as that Lucide icon", () => {
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
});
});
it("draws a component with the surface's size and color", () => {
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
type: "span",
props: { "data-size": 18, "data-color": "#123456" },
});
});
});
describe("parsePluginIcon", () => {
it("normalizes each icon form", () => {
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
});
it.each([
["", "Workspace panel files has no icon"],
[
"NotALucideIcon",
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
],
[
{ svg: "<svg><script>run()</script></svg>" },
"Workspace panel files has an invalid icon: script elements are not allowed",
],
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
[
{ path: "icon.svg" },
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
],
])("rejects %j", (icon, message) => {
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
});
it("refuses a component where the icon is stored as data", () => {
expect(() =>
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
).toThrow(
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
);
});
});
describe("Icon", () => {
it("renders a host Lucide icon by name with the requested presentation", () => {
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders a plugin's own SVG through the icon prop", () => {
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, color: "#123456" },
});
});
it("renders nothing for an unusable icon", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
});
});
+13 -139
View File
@@ -1,150 +1,24 @@
import { createElement, type ComponentType, type ReactElement } from "react";
import { createElement, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
import type { PluginIconProps } from "@getpaseo/plugin/client";
import type { LucideIcon } from "lucide-react-native";
import {
findSvgIconProblem,
type PluginIconProps,
type PluginSerializableIcon,
type PluginSvgIcon,
} from "@getpaseo/plugin";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { IconProps } from "@getpaseo/plugin/client/react-native";
import { SvgIcon } from "@/components/provider-icons";
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
type HostIconProps = PluginIconProps & { strokeWidth?: number };
/** A host-drawn icon. A plain function, so it stands in for any plugin icon component type. */
type HostIcon = (props: HostIconProps) => ReactElement | null;
const lucideIconComponents = new Map<string, HostIcon>();
const svgIconComponents = new Map<string, HostIcon>();
function findLucideIcon(name: string): LucideIcon | null {
function findPluginIcon(name: string): LucideIcon | null {
const candidate = Reflect.get(LucideIcons, name);
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
return isComponent(candidate) ? (candidate as LucideIcon) : null;
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
return isComponent ? (candidate as LucideIcon) : null;
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
(typeof value === "object" && value !== null && "$$typeof" in value)
);
}
function isSvgIcon(icon: unknown): icon is PluginSvgIcon {
return (
typeof icon === "object" &&
icon !== null &&
!("$$typeof" in icon) &&
typeof Reflect.get(icon, "svg") === "string"
);
}
function isComponentIcon<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): icon is ComponentType<Props> {
return isComponent(icon);
}
function invalidIcon(owner: string, reason: string): Error {
return new Error(`${owner} has an invalid icon: ${reason}`);
}
/** Validates an icon that is stored with the data it labels. Returns the normalized icon. */
export function parseSerializablePluginIcon(
owner: string,
icon: PluginSerializableIcon,
): PluginSerializableIcon {
if (typeof icon === "string") {
const name = icon.trim();
if (!name) throw new Error(`${owner} has no icon`);
if (!findLucideIcon(name)) throw invalidIcon(owner, `unknown Lucide icon "${name}"`);
return name;
}
if (!isSvgIcon(icon)) throw invalidIcon(owner, "expected a Lucide icon name or { svg }");
const svg = icon.svg.trim();
const problem = findSvgIconProblem(svg);
if (problem) throw invalidIcon(owner, problem);
return { svg };
}
/** Validates a contribution's icon once, at registration. Rendering trusts the result. */
export function parsePluginIcon<Props extends PluginIconProps>(
owner: string,
icon: PluginIcon<Props>,
): PluginIcon<Props> {
if (isComponentIcon(icon)) return icon;
if (typeof icon !== "string" && !isSvgIcon(icon)) {
throw invalidIcon(owner, "expected a Lucide icon name, { svg }, or a component");
}
return parseSerializablePluginIcon(owner, icon);
}
function MissingIcon(): null {
return null;
}
function createLucideIcon(name: string, Lucide: LucideIcon): HostIcon {
function PluginLucideIcon({ size, color, strokeWidth }: HostIconProps) {
return createElement(Lucide, { size, color, strokeWidth });
}
PluginLucideIcon.displayName = `PluginLucideIcon(${name})`;
return PluginLucideIcon;
}
function lucideIconComponent(name: string): HostIcon {
const cached = lucideIconComponents.get(name);
if (cached) return cached;
const Lucide = findLucideIcon(name);
if (!Lucide) return MissingIcon;
const icon = createLucideIcon(name, Lucide);
lucideIconComponents.set(name, icon);
export function resolvePluginIcon(name: string): LucideIcon {
const icon = findPluginIcon(name);
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
return icon;
}
function svgIconComponent(svg: string): HostIcon {
const cached = svgIconComponents.get(svg);
if (cached) return cached;
function PluginSvgIcon({ size, color }: PluginIconProps) {
return createElement(SvgIcon, { svg, size, color });
}
svgIconComponents.set(svg, PluginSvgIcon);
return PluginSvgIcon;
}
/**
* A stable component for a registered icon, for surfaces that take an icon component. Lucide and
* SVG icons draw from `size` and `color` only; a plugin component receives every prop.
*/
export function pluginIconComponent<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): ComponentType<Props> {
if (typeof icon === "string") return lucideIconComponent(icon);
if (isSvgIcon(icon)) return svgIconComponent(icon.svg);
return icon;
}
export interface PluginIconViewProps extends PluginIconProps {
icon: PluginIcon;
}
/** Draws a registered plugin icon. `color` tints Lucide icons and `currentColor` in SVG. */
export function PluginIconView({ icon, size, color }: PluginIconViewProps): ReactElement {
return createElement(pluginIconComponent(icon), { size, color });
}
function isRenderable(icon: PluginIcon): boolean {
if (typeof icon === "string") return findLucideIcon(icon) !== null;
if (isSvgIcon(icon)) return findSvgIconProblem(icon.svg) === null;
return isComponent(icon);
}
/** The kit `Icon`. Plugin code passes it anything, so an unusable icon renders nothing. */
export function Icon(props: IconProps): ReactElement | null {
const icon = props.icon === undefined ? props.name : props.icon;
if (!isRenderable(icon)) return null;
const size = props.size ?? 24;
const color = props.color ?? "currentColor";
return createElement(PluginIconView, { icon, size, color });
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
const icon = findPluginIcon(name);
return icon ? createElement(icon, { size, color }) : null;
}
+1 -2
View File
@@ -9,7 +9,6 @@ import type {
PluginSidebarItemContribution,
PluginSidebarSection,
} from "./types";
import type { PluginIcon } from "@getpaseo/plugin/client";
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
@@ -37,7 +36,7 @@ export type PluginSidebarGroup =
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
| (PluginSidebarGroupBase & {
kind: "legacy";
icon: PluginIcon;
icon: string;
targets: PluginLegacySidebarTarget[];
});
@@ -1,12 +1,16 @@
import type { SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useRef } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { parsePluginIcon, pluginIconComponent } from "../icons";
import { resolvePluginIcon } from "../icons";
import { useSidebarItemFrame } from "./frame";
function resolveIcon(icon: SidebarIcon) {
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
}
/**
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
@@ -25,11 +29,6 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
anchorTo(rowRef.current);
return false;
}, [anchorTo]);
// Rows get their icon at render, not registration, so an invalid icon throws to the item's boundary.
const iconComponent = useMemo(
() => (icon ? pluginIconComponent(parsePluginIcon("Sidebar row", icon)) : null),
[icon],
);
// Keyboard activation skips the responder system, so the row's own press anchors as well.
const handlePress = useCallback(() => {
anchorTo(rowRef.current);
@@ -38,7 +37,7 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
return (
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
<SidebarHeaderRow
icon={iconComponent}
icon={icon ? resolveIcon(icon) : null}
label={label ?? frame.title}
onPress={handlePress}
isActive={active}
@@ -2,7 +2,7 @@ import { router, usePathname } from "expo-router";
import { useCallback } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { rememberPluginContributionHost } from "../contribution-host";
import { pluginIconComponent } from "../icons";
import { resolvePluginIcon } from "../icons";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
@@ -33,7 +33,7 @@ export function LegacyPluginSidebarRow({
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={pluginIconComponent(group.icon)}
icon={resolvePluginIcon(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
+14 -8
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { X } from "lucide-react-native";
import { useCallback, useMemo, type ComponentType } from "react";
@@ -17,7 +17,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { PluginIconView } from "./icons";
import { resolvePluginIcon } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
@@ -47,8 +47,14 @@ function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
return <PluginIconView icon={icon} size={16} color={color} />;
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
@@ -156,7 +162,7 @@ export function PluginSurfaceScreen() {
: (sidebarItem?.title ?? (pluginId || "Plugin")),
[params, pluginId, sidebarItem, surface],
);
const icon = sidebarItem?.icon ?? null;
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
@@ -166,15 +172,15 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
{icon ? (
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
</>
),
[icon, title],
[Icon, title],
);
const hostFilter = useMemo(
() =>
@@ -33,7 +33,7 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
pluginIconComponent: () => () => null,
resolvePluginIcon: () => () => null,
}));
import { pluginRegistry } from "../registry";
@@ -17,7 +17,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { usePluginHostNavigation } from "../host-navigation";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { pluginIconComponent } from "../icons";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugin } from "../registry";
import { PluginInstallationProvider } from "../installation-provider";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
@@ -144,7 +144,7 @@ function usePluginPanelDescriptor(
subtitle: target.pluginId,
tooltip: panel.title,
titleState: "ready",
icon: pluginIconComponent(panel.icon),
icon: resolvePluginIcon(panel.icon),
statusBucket: null,
};
}
@@ -10,7 +10,6 @@ import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
@@ -231,38 +230,3 @@ it("does not restore a targeted cached workspace while its archive is pending",
store.clearSession(serverId);
}
});
it("setup metadata from directory snapshots and transitions resets failure surfacing without details", () => {
const serverId = "setup-metadata";
useSessionStore.getState().initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const identity = { serverId, workspaceId: "setup" };
const store = useWorkspaceSetupStore.getState();
replica.commitSnapshot(
{
workspaces: new Map([
["setup", normalizeWorkspaceDescriptor({ ...workspace("setup"), setupStatus: "blocked" })],
]),
projects: new Map(),
},
[],
);
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
"blocked",
);
expect(store.claimFailedSetupSurface({ ...identity, status: "blocked" })).toBe(true);
replica.applyDelta({
kind: "upsert",
workspace: { ...workspace("setup"), setupStatus: "running" },
});
replica.applyDelta({
kind: "upsert",
workspace: { ...workspace("setup"), setupStatus: "failed" },
});
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
"failed",
);
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(true);
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
});
@@ -102,7 +102,6 @@ export class WorkspaceDirectoryReplica {
workspaces.set(workspaceId, workspace);
}
}
for (const workspace of workspaces.values()) this.applySetupStatus(workspace);
this.workspaces = workspaces;
this.projects = new Map(snapshot.projects);
this.rebuildProjectIndex();
@@ -199,16 +198,7 @@ export class WorkspaceDirectoryReplica {
return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }];
}
private applySetupStatus(workspace: WorkspaceDescriptor): void {
useWorkspaceSetupStore.getState().applySetupStatus({
serverId: this.serverId,
workspaceId: workspace.id,
status: workspace.setupStatus ?? null,
});
}
private setWorkspace(workspace: WorkspaceDescriptor): void {
this.applySetupStatus(workspace);
const previous = this.workspaces.get(workspace.id);
if (previous?.projectId !== workspace.projectId) {
this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id);
@@ -1,3 +1,4 @@
import { ConnectedHostsSection } from "@/connected-hosts";
import {
ArrowDown,
ArrowUp,
@@ -374,6 +375,7 @@ export function HostSettingsPage({
<HostConnectionError serverId={serverId} />
<HostAppearanceSection host={host} />
<ConnectedHostsSection serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
@@ -20,7 +20,6 @@ import {
buildKeyboardShortcutHelpSections,
getBindingIdForAction,
normalizeCapturedShortcutCombo,
isShortcutCaptureComplete,
getDefaultKeysForAction,
resolveShortcutKeysForAction,
type KeyboardShortcutHelpRow,
@@ -270,8 +269,6 @@ function ShortcutRow({
const isBindable = bindingId !== null;
const showDone = isCapturing && capturedCombos.length > 0;
const captureComplete =
bindingId !== null && isShortcutCaptureComplete(bindingId, capturedCombos);
const showClear = displayChord !== null;
// Reset restores the default, so it is only meaningful when there is a
// default to restore. A binding that ships without one would otherwise show a
@@ -291,14 +288,11 @@ function ShortcutRow({
capturedCombos={capturedCombos}
heldModifiers={heldModifiers}
/>
{showDone && !captureComplete && (
<Text style={styles.capturingText}>{t("settings.shortcuts.captureIndexPrompt")}</Text>
)}
</View>
{isCapturing ? (
<>
{showDone && (
<Button variant="ghost" size="sm" disabled={!captureComplete} onPress={onDone}>
<Button variant="ghost" size="sm" onPress={onDone}>
{t("settings.shortcuts.actions.done")}
</Button>
)}
@@ -363,10 +357,7 @@ export function KeyboardShortcutsSection() {
);
const saveCapture = useCallback(() => {
if (
capturingBindingId === null ||
!isShortcutCaptureComplete(capturingBindingId, capturedCombos)
) {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
@@ -1,4 +1,4 @@
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
import { useCallback, useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -26,7 +26,7 @@ import {
type SidebarNavItem,
type SidebarSection,
} from "@/sidebar-nav/model";
import { pluginIconComponent } from "@/plugins/icons";
import { resolvePluginIcon } from "@/plugins/icons";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -50,17 +50,15 @@ const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
const PLUGIN_ICON = Blocks;
type NavIconComponent = ComponentType<{ size: number; color: string }>;
function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) {
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedNavIcon = withUnistyles(NavIcon);
function navItemIcon(item: SidebarNavItem): NavIconComponent {
function navItemIcon(item: SidebarNavItem): LucideIcon {
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON;
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
}
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
@@ -1,4 +1,3 @@
import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
@@ -224,10 +223,6 @@ const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
function getWorkspaceSetupStatus(workspace: WorkspaceDescriptor | null | undefined) {
return workspace?.setupStatus ?? null;
}
function getWorkspaceScripts(
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
): WorkspaceDescriptor["scripts"] {
@@ -1318,13 +1313,12 @@ function WorkspaceScreenContent({
});
const lastMainPaneId = lastMainPaneRef.current.paneId;
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const workspaceSetupStatus = getWorkspaceSetupStatus(workspaceDescriptor);
const claimFailedSetupSurface = useWorkspaceSetupStore((state) => state.claimFailedSetupSurface);
const supportsSetupObservation = useHostFeature(normalizedServerId, "workspaceSetupObservation");
const showWorkspaceSetup = shouldShowWorkspaceSetup(
workspaceSetupStatus,
supportsSetupObservation,
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
);
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
const claimFailedSetupSurface = useWorkspaceSetupStore((state) => state.claimFailedSetupSurface);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
@@ -1630,7 +1624,7 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
if (!shouldSeedWorkspaceSetupTab(workspaceSetupStatus)) {
if (!shouldSeedWorkspaceSetupTab(workspaceSetupSnapshot)) {
return;
}
@@ -1645,7 +1639,6 @@ function WorkspaceScreenContent({
!claimFailedSetupSurface({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
status: workspaceSetupStatus,
})
) {
return;
@@ -1666,7 +1659,24 @@ function WorkspaceScreenContent({
normalizedServerId,
openWorkspaceTabInBackground,
persistenceKey,
workspaceSetupStatus,
workspaceSetupSnapshot,
]);
useEffect(() => {
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
return;
}
ensureWorkspaceSetupStatus({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
client,
});
}, [
client,
ensureWorkspaceSetupStatus,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
]);
const handleOpenFileFromChat = useCallback(
@@ -63,66 +63,3 @@ describe("createValidatedPersistStorage", () => {
expect(backing.values.has("settings")).toBe(false);
});
});
it("skips repeated immutable states only when requested", async () => {
let writes = 0;
const backing: StateStorage = {
getItem: () => null,
setItem: () => {
writes += 1;
},
removeItem: () => {},
};
const value = { state: { enabled: true }, version: 1 };
const regular = createValidatedPersistStorage(backing, StateSchema);
await regular.setItem("regular", value);
await regular.setItem("regular", value);
expect(writes).toBe(2);
const deduplicated = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
await deduplicated.setItem("first", value);
await deduplicated.setItem("first", { ...value });
expect(writes).toBe(3);
await deduplicated.setItem("second", value);
expect(writes).toBe(4);
await deduplicated.setItem("first", { ...value, version: 2 });
expect(writes).toBe(5);
});
it("retries an unchanged state after a failed save", async () => {
let attempts = 0;
const backing = new MemoryStorage();
const storage = createValidatedPersistStorage(
{
getItem: (name) => backing.getItem(name),
setItem: (name, value) => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error("storage unavailable"));
backing.setItem(name, value);
},
removeItem: (name) => backing.removeItem(name),
},
StateSchema,
{ skipUnchanged: true },
);
const value = { state: { enabled: true }, version: 1 };
await expect(storage.setItem("layout", value)).rejects.toThrow("storage unavailable");
await storage.setItem("layout", value);
expect(attempts).toBe(2);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
});
it("saves the same state again after removal or rehydration clears invalid storage", async () => {
const backing = new MemoryStorage();
const storage = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
const value = { state: { enabled: true }, version: 1 };
await storage.setItem("layout", value);
await storage.removeItem("layout");
await storage.setItem("layout", value);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
backing.values.set("layout", "{");
await expect(storage.getItem("layout")).resolves.toBeNull();
await storage.setItem("layout", value);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
});
@@ -1,22 +1,17 @@
import { z } from "zod";
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
import type { PersistStorage, StateStorage } from "zustand/middleware";
export function createValidatedPersistStorage<State>(
backingStorage: StateStorage,
stateSchema: z.ZodType<State>,
// Opt in only when callers preserve state references until values change.
options: { skipUnchanged?: boolean } = {},
): PersistStorage<State> {
const envelopeSchema = z.strictObject({
state: stateSchema,
version: z.number().int().nonnegative().optional(),
});
const accepted = new Map<string, StorageValue<State>>();
return {
getItem: async (name) => {
accepted.delete(name);
const raw = await backingStorage.getItem(name);
if (raw === null) return null;
@@ -36,31 +31,13 @@ export function createValidatedPersistStorage<State>(
return result.data;
},
setItem: async (name, value) => {
const previous = accepted.get(name);
if (
options.skipUnchanged &&
previous &&
previous.state === value.state &&
previous.version === value.version
)
return;
const result = envelopeSchema.safeParse(value);
if (!result.success) {
accepted.delete(name);
await backingStorage.removeItem(name);
return;
}
if (options.skipUnchanged) accepted.set(name, value);
try {
await backingStorage.setItem(name, JSON.stringify(result.data));
} catch (error) {
if (accepted.get(name) === value) accepted.delete(name);
throw error;
}
},
removeItem: (name) => {
accepted.delete(name);
return backingStorage.removeItem(name);
await backingStorage.setItem(name, JSON.stringify(result.data));
},
removeItem: (name) => backingStorage.removeItem(name),
};
}
-3
View File
@@ -111,7 +111,6 @@ export interface WorkspaceDescriptor {
projectRootPath: string;
workspaceDirectory: string;
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
setupStatus?: WorkspaceDescriptorPayload["setupStatus"];
projectKind: WorkspaceDescriptorPayload["projectKind"];
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
background?: boolean;
@@ -150,8 +149,6 @@ export function normalizeWorkspaceDescriptor(
// usable directory" (older daemons may omit it; the wire field is optional).
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
worktreeSlug: payload.worktreeSlug,
// COMPAT(workspaceSetupObservation): added in v0.12.0, old hosts omit metadata; remove after 2027-04-19.
setupStatus: payload.setupStatus ?? null,
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
background: payload.background ?? false,
@@ -1,54 +0,0 @@
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
Object.assign(globalThis, { indexedDB, IDBKeyRange });
it("skips unchanged reconciliations and immediately saves real layout changes", async () => {
const values = new Map<string, string>();
let writes = 0;
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
writes += 1;
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const store = createWorkspaceLayoutStore(undefined, storage);
await store.persist.rehydrate();
const workspaceKey = "server:workspace";
const openAgent = (agentId: string) =>
store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId },
intent: "reveal",
});
openAgent("agent-1");
const layout = store.getState().layoutByWorkspace[workspaceKey];
const writesBefore = writes;
expect(writesBefore).toBe(1);
for (let index = 0; index < 50; index += 1) {
store.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
}
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layout);
expect(writes).toBe(writesBefore);
openAgent("agent-2");
expect(writes).toBe(writesBefore + 1);
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(
store.getState().layoutByWorkspace[workspaceKey],
);
});
@@ -1,7 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { persist, type StateStorage } from "zustand/middleware";
import { persist } from "zustand/middleware";
import type { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
@@ -224,7 +224,6 @@ const MAX_TREE_DEPTH = 5;
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
type WorkspaceLayoutPersistedState = z.infer<typeof WorkspaceLayoutPersistedStateSchema>;
function convertLegacyExplorerSidebarRatios(
ratiosByWorkspace: Record<string, number>,
@@ -940,57 +939,8 @@ function createExplorerSidebarPane(
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
}
const PERSISTED_SLICES = [
"layoutByWorkspace",
"pinnedAgentIdsByWorkspace",
"splitSizesByWorkspace",
"explorerSidebarWidthByWorkspace",
"explorerSidebarPaneIdByWorkspace",
"sidePaneIdByWorkspace",
"pullRequestTabAutoOpenedByWorkspace",
] as const;
// Zustand persist re-runs partialize after every set, including ones that return the state
// untouched. Reusing the previous projection while every persisted slice is the same
// reference lets validated storage skip unchanged writes before validation and serialization.
function createPersistedStateProjection() {
let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null =
null;
return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => {
const last = previous;
if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) {
return last.state;
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in source.layoutByWorkspace) {
// Strip ephemeral (commit diff) tabs before persisting so they are
// dropped on reload rather than restored pointing at a rebased SHA.
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
normalizeLayout(source.layoutByWorkspace[key]),
);
}
const state: WorkspaceLayoutPersistedState = {
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
key,
Array.from(agentIds),
]),
),
splitSizesByWorkspace: source.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace,
sidePaneIdByWorkspace: source.sidePaneIdByWorkspace,
pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace,
};
previous = { source, state };
return state;
};
}
export function createWorkspaceLayoutStore(
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
storage: StateStorage = AsyncStorage,
) {
return create<WorkspaceLayoutStore>()(
persist(
@@ -2167,12 +2117,33 @@ export function createWorkspaceLayoutStore(
{
name: "workspace-layout-state",
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
skipUnchanged: true,
}),
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
migrate: (persistedState, version) =>
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
partialize: createPersistedStateProjection(),
partialize: (state) => {
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) {
// Strip ephemeral (commit diff) tabs before persisting so they are
// dropped on reload rather than restored pointing at a rebased SHA.
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
normalizeLayout(state.layoutByWorkspace[key]),
);
}
return {
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
key,
Array.from(agentIds),
]),
),
splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
};
},
merge: (persistedState, currentState) => {
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
@@ -3,13 +3,129 @@ import {
shouldSeedWorkspaceSetupTab,
shouldShowWorkspaceSetup,
useWorkspaceSetupStore,
type WorkspaceSetupSnapshot,
type WorkspaceSetupStatusClient,
type WorkspaceSetupStatusResult,
} from "./workspace-setup-store";
function makeSnapshot(
status: WorkspaceSetupSnapshot["status"],
error: string | null = null,
): WorkspaceSetupSnapshot {
return {
workspaceId: "workspace-1",
status,
detail: {
type: "worktree_setup",
worktreePath: "/Users/test/project",
branchName: "main",
log: "",
commands: [],
},
error,
updatedAt: Date.now(),
};
}
const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = {
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/Users/test/project",
branchName: "main",
log: "",
commands: [],
},
error: null,
};
function setupResult(
workspaceId: string,
snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT,
): WorkspaceSetupStatusResult {
return { requestId: "req-1", workspaceId, snapshot };
}
function makeClient(handler: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>) {
const calls: string[] = [];
const client: WorkspaceSetupStatusClient = {
fetchWorkspaceSetupStatus: (workspaceId) => {
calls.push(workspaceId);
return handler(workspaceId);
},
};
return { client, calls };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function storedSnapshots() {
return Object.values(useWorkspaceSetupStore.getState().snapshots);
}
function resolveDefault(workspaceId: string): Promise<WorkspaceSetupStatusResult> {
return Promise.resolve(setupResult(workspaceId));
}
function rejectThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.reject(new Error("boom"));
}
return resolveDefault(workspaceId);
};
}
function nullThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(setupResult(workspaceId, null));
}
return resolveDefault(workspaceId);
};
}
function mismatchThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(setupResult("999"));
}
return resolveDefault(workspaceId);
};
}
function ensureSetupStatus(client: WorkspaceSetupStatusClient) {
useWorkspaceSetupStore.getState().ensureSetupStatus({
serverId: "server-1",
workspaceId: "42",
client,
});
}
describe("workspace-setup-store", () => {
beforeEach(() => {
useWorkspaceSetupStore.setState({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
surfacedFailedSetupKeys: new Set(),
});
});
@@ -46,39 +162,213 @@ describe("workspace-setup-store", () => {
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
});
it("uses directory metadata to surface failures without requesting setup details", () => {
for (const status of [null, "running", "completed", "failed", "blocked"] as const) {
expect(shouldShowWorkspaceSetup(status)).toBe(status !== null);
expect(shouldSeedWorkspaceSetupTab(status)).toBe(status === "failed" || status === "blocked");
}
expect(shouldShowWorkspaceSetup(null, false)).toBe(true);
const identity = { serverId: "server-1", workspaceId: "42", status: "failed" as const };
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(false);
useWorkspaceSetupStore.getState().applySetupStatus({ ...identity, status: "running" });
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
});
it("replaces cached setup details with an empty reconnect snapshot", () => {
const identity = { serverId: "server", workspaceId: "workspace" };
const store = useWorkspaceSetupStore.getState();
store.applySnapshot({
...identity,
snapshot: {
status: "failed",
error: "previous setup failed",
it("hides empty successful setup snapshots", () => {
expect(
shouldShowWorkspaceSetup({
workspaceId: "workspace-1",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/workspace",
worktreePath: "/Users/test/project",
branchName: "main",
log: "",
commands: [],
log: "previous output",
},
error: null,
updatedAt: Date.now(),
}),
).toBe(false);
});
it("shows setup snapshots with commands or errors", () => {
expect(
shouldShowWorkspaceSetup({
workspaceId: "workspace-1",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/Users/test/project",
branchName: "main",
log: "done\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/Users/test/project",
log: "done\n",
status: "completed",
exitCode: 0,
},
],
},
error: null,
updatedAt: Date.now(),
}),
).toBe(true);
expect(
shouldShowWorkspaceSetup({
workspaceId: "workspace-1",
status: "failed",
detail: {
type: "worktree_setup",
worktreePath: "/Users/test/project",
branchName: "main",
log: "",
commands: [],
},
error: "Failed to parse paseo.json",
updatedAt: Date.now(),
}),
).toBe(true);
});
it("seeds a setup tab for failed or blocked setup", () => {
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true);
});
it("claims one failed setup surface until a later setup lifecycle begins", () => {
const store = useWorkspaceSetupStore.getState();
const identity = { serverId: "server-1", workspaceId: "42" };
store.upsertProgress({
serverId: identity.serverId,
payload: { ...makeSnapshot("failed", "Setup failed"), workspaceId: identity.workspaceId },
});
expect(store.claimFailedSetupSurface(identity)).toBe(true);
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(false);
useWorkspaceSetupStore.getState().upsertProgress({
serverId: identity.serverId,
payload: { ...makeSnapshot("running"), workspaceId: identity.workspaceId },
});
useWorkspaceSetupStore.getState().upsertProgress({
serverId: identity.serverId,
payload: {
...makeSnapshot("failed", "Setup failed again"),
workspaceId: identity.workspaceId,
},
});
expect(Object.values(useWorkspaceSetupStore.getState().snapshots)).toHaveLength(1);
store.applySnapshot({ ...identity, snapshot: null });
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
});
it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => {
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toEqual([
expect.objectContaining({ workspaceId: "42", status: "running" }),
]);
});
it("ensureSetupStatus does not refetch while a request is in flight", async () => {
const deferred = createDeferred<WorkspaceSetupStatusResult>();
const { client, calls } = makeClient(() => deferred.promise);
ensureSetupStatus(client);
ensureSetupStatus(client);
expect(calls).toEqual(["42"]);
deferred.resolve(setupResult("42"));
await flush();
ensureSetupStatus(client);
expect(calls).toEqual(["42"]);
});
it("ensureSetupStatus skips fetching when a snapshot already exists", () => {
useWorkspaceSetupStore.getState().upsertProgress({
serverId: "server-1",
payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT },
});
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
expect(calls).toEqual([]);
});
it("ensureSetupStatus ignores a response for a different workspace", async () => {
const { client } = makeClient(() => Promise.resolve(setupResult("999")));
ensureSetupStatus(client);
await flush();
expect(storedSnapshots()).toHaveLength(0);
});
it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => {
const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null)));
ensureSetupStatus(client);
await flush();
expect(storedSnapshots()).toHaveLength(0);
});
it("ensureSetupStatus retries after a null-snapshot response", async () => {
const { client, calls } = makeClient(nullThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus retries after a mismatched-workspace response", async () => {
const { client, calls } = makeClient(mismatchThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => {
const { client, calls } = makeClient(rejectThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus retries after the workspace is removed", async () => {
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" });
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
});
});
@@ -1,9 +1,6 @@
import { createNameId } from "mnemonic-id";
import { generateDraftId } from "@/stores/draft-keys";
import type {
WorkspaceSetupSnapshot as SetupDetailSnapshot,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
@@ -22,18 +19,30 @@ export type WorkspaceSetupProgressPayload = Extract<
{ type: "workspace_setup_progress" }
>["payload"];
export type WorkspaceSetupStatusResult = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_status_response" }
>["payload"];
export interface WorkspaceSetupStatusClient {
fetchWorkspaceSetupStatus: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>;
}
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
updatedAt: number;
}
export type WorkspaceSetupStatus = WorkspaceSetupSnapshot["status"] | null;
export function shouldShowWorkspaceSetup(status: WorkspaceSetupStatus, supported = true): boolean {
return !supported || status !== null;
export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null): boolean {
if (!snapshot) {
return false;
}
return (
snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0
);
}
export function shouldSeedWorkspaceSetupTab(status: WorkspaceSetupStatus): boolean {
return status === "failed" || status === "blocked";
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
return snapshot?.status === "failed" || snapshot?.status === "blocked";
}
interface WorkspaceSetupStoreState {
@@ -41,24 +50,17 @@ interface WorkspaceSetupStoreState {
| (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string })
| null;
snapshots: Record<string, WorkspaceSetupSnapshot>;
requestedKeys: Set<string>;
surfacedFailedSetupKeys: Set<string>;
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
clearWorkspaceSetup: () => void;
applySnapshot: (input: {
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
claimFailedSetupSurface: (input: { serverId: string; workspaceId: string }) => boolean;
ensureSetupStatus: (input: {
serverId: string;
workspaceId: string;
snapshot: SetupDetailSnapshot | null;
client: WorkspaceSetupStatusClient;
}) => void;
applySetupStatus: (input: {
serverId: string;
workspaceId: string;
status: WorkspaceSetupStatus;
}) => void;
claimFailedSetupSurface: (input: {
serverId: string;
workspaceId: string;
status: WorkspaceSetupStatus;
}) => boolean;
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
clearServer: (serverId: string) => void;
}
@@ -67,9 +69,10 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }
return buildWorkspaceTabPersistenceKey(input);
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, get) => ({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
surfacedFailedSetupKeys: new Set(),
beginWorkspaceSetup: (value) => {
set({
@@ -83,28 +86,30 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) =
clearWorkspaceSetup: () => {
set({ pendingWorkspaceSetup: null });
},
applySnapshot: ({ serverId, workspaceId, snapshot }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) return;
upsertProgress: ({ serverId, payload }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
if (!key) {
return;
}
set((state) => {
const snapshots = { ...state.snapshots };
if (snapshot) snapshots[key] = { ...snapshot, workspaceId, updatedAt: Date.now() };
else delete snapshots[key];
return { snapshots };
});
},
applySetupStatus: ({ serverId, workspaceId, status }) => {
if (shouldSeedWorkspaceSetupTab(status)) return;
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) return;
set((state) => {
if (!state.surfacedFailedSetupKeys.has(key)) return state;
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
surfacedFailedSetupKeys.delete(key);
return { surfacedFailedSetupKeys };
if (payload.status !== "failed" && payload.status !== "blocked") {
surfacedFailedSetupKeys.delete(key);
}
return {
snapshots: {
...state.snapshots,
[key]: {
...payload,
updatedAt: Date.now(),
},
},
surfacedFailedSetupKeys,
};
});
},
claimFailedSetupSurface: ({ serverId, workspaceId, status }) => {
claimFailedSetupSurface: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return false;
@@ -112,7 +117,10 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) =
let claimed = false;
set((state) => {
if (!shouldSeedWorkspaceSetupTab(status) || state.surfacedFailedSetupKeys.has(key)) {
if (
!["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") ||
state.surfacedFailedSetupKeys.has(key)
) {
return state;
}
claimed = true;
@@ -120,6 +128,40 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) =
});
return claimed;
},
ensureSetupStatus: async ({ serverId, workspaceId, client }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
const state = get();
if (state.snapshots[key] || state.requestedKeys.has(key)) {
return;
}
// requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is
// released once the request settles. A settle that stored no snapshot (null snapshot,
// mismatched workspace, or error) leaves no marker, so a later call can retry; once a
// snapshot lands, the snapshots[key] guard above prevents redundant refetches.
set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) }));
try {
const response = await client.fetchWorkspaceSetupStatus(workspaceId);
if (response.workspaceId === workspaceId && response.snapshot) {
get().upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
} catch {
// Swallowed: the finally clears the in-flight marker so a later call retries.
} finally {
set((current) => {
const next = new Set(current.requestedKeys);
next.delete(key);
return { requestedKeys: next };
});
}
},
removeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
import invariant from "tiny-invariant";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { pluginIconComponent } from "@/plugins/icons";
import { resolvePluginIcon } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
@@ -212,7 +212,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
pluginItems.push({
id: `plugin:${plugin.id}:${panel.id}`,
label: panel.title,
Icon: pluginIconComponent(panel.icon),
Icon: resolvePluginIcon(panel.icon),
disabled: false,
panelKind: "plugin",
launch: launchSelection(selection),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3
View File
@@ -1,3 +1,4 @@
import { createHostCommand } from "./commands/host/index.js";
import { pairCommand } from "./commands/daemon/pair.js";
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
@@ -58,6 +59,8 @@ export function createCli(): Command {
.option("--no-color", "disable colored output");
addDaemonHostOption(program);
program.addCommand(createHostCommand());
// Primary agent commands (top-level)
addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand));

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