Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4e60c2880b chore(release): cut 0.7.0-beta.2 2026-08-28 18:35:49 +02:00
Mohamed Boudra 510074f555 docs: add v0.7.0-beta.2 changelog 2026-08-28 18:28:12 +02:00
Christoph Leiter ed628ff82e feat(app): add workspace actions to the command center (#3013)
Eight actions were only reachable through the sidebar workspace menu,
the workspace header menu, or a keybind you had to remember: Rename
workspace, Copy workspace path, Copy branch name, Pin/Unpin, the three
layout toggles, and Show setup. All eight are now in the command
center, plus a new one: label assignment. Type a label's name to
assign or unassign it on the active workspace without leaving the
keyboard.

Rename needed more than a palette entry. Its dialog lived inside the
sidebar row component, so it disappeared whenever that row was not
rendered: a collapsed project or status group, a collapsed Pinned
section, or focus mode. It now has a global host keyed on the active
workspace selection, mirroring use-global-workspace-pin-action.ts.

Each entry reuses the handler the existing menu item already calls, so
behaviour matches the menu. Entries that cannot act are omitted rather
than present and failing: Copy branch name without a branch, Pin when
the host lacks workspacePinning, Show setup when there is nothing to
show, and labels when the catalog has not loaded.

Toggle Explorer sidebar and Toggle focus mode are registered as
workspace contributions, not global ones. Their handlers live in
workspace-screen.tsx behind an isRouteFocused gate, so a global
registration would have listed two entries that close the palette and
do nothing on /settings, /sessions, /schedules and Home.

The copy-path, copy-branch, and rename-dialog logic was duplicated
across three sidebar files. It moves into one hook and one component so
the palette is not a fourth copy. That also fixes drift in
sidebar-status-list.tsx, where Copy branch name copied the workspace
name instead of the branch and four strings were hardcoded English.
This supersedes #3383, which fixes the same bug directly without the
shared-hook refactor.

Toggle focus mode also had a hidden duplicate: buildPaneContributions
already dispatches the same action once split panes are available, so
the standalone entry now steps aside there instead of listing the
toggle twice.

No new i18n keys.
2026-08-28 17:43:46 +02:00
667664847d Connect to remote daemons over SSH (#3989)
Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations.

Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits.

Closes #3949

Co-authored-by: James Strain <jstrain@nvidia.com>
Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
2026-08-28 16:32:12 +02:00
Mohamed Boudra 7ea4288117 Keep restored workspaces out of reconciliation loops (#3987)
* fix(app): stop workspace tab reconciliation loop

Duplicate startup tabs could move workspace focus into Explorer. Once hydration removed the stale agent, empty-workspace reconciliation kept seeding drafts into Explorer and retriggering itself. Preserve ordinary-pane focus at the reconciliation boundary.

* fix(app): normalize workspace focus before reconciliation

Persisted Explorer focus must not steer newly reconciled tabs into the sidebar before the output invariant is applied.
2026-08-28 15:50:44 +02:00
Mohamed Boudra 42b6d99d60 Wait for in-flight dictation commits before finishing (#3968)
* fix(dictation): preserve audio queued at stop

Stopping capture disabled native recording before the final microphone buffer had crossed the bridge, so accepting immediately could omit the end of a dictation. Wait for the native producer to publish its final buffer before completing stop, and serialize stop with restart and destruction.

* fix(voice): serialize capture lifecycle

A capture start already awaiting permission could resume after teardown, and repeated destroy calls did not share completion. Order capture lifecycle operations behind one promise and build the native module declarations before clean-CI typechecking.

* fix(dictation): drain queued web capture before finish

* fix(dictation): await in-flight segment commits

* Revert "fix(dictation): drain queued web capture before finish"

This reverts commit 5168a6554d.

* Revert "fix(voice): serialize capture lifecycle"

This reverts commit 00fd3e3556.

* Revert "fix(dictation): preserve audio queued at stop"

This reverts commit ec087dc9cd.
2026-08-28 15:29:42 +02:00
paseo-bot[bot] 23debf3af1 fix(server): honor configured commit signing (#3976)
Paseo forced signing off for commits created through the app and squash merges. Let Git apply the user's configured policy; the existing command timeout still bounds interactive signer waits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-28 19:24:56 +08:00
Mohamed Boudra 25eb322ef7 fix(app): prevent archived workspaces returning from cache (#3975)
Durable replica reads could race accepted directory replacements and restore rows that had already been removed in memory. Serialize reads behind pending writes and discard reads when the host changes in flight; local archives also suppress targeted cache restoration until removal is acknowledged.
2026-08-28 13:18:26 +02:00
Mohamed Boudra deb846eed6 Let plugins use Paseo modals, icons, and toasts (#3967)
* feat(plugins): add React Native UI primitives

Give client plugins host-owned adaptive UI without exposing app internals. Preserve plugin runtime contexts when modal content moves through the compact portal host.

* docs(plugins): clarify host UI reference

* refactor(plugins): harden host UI boundary

Replace mocked pass-through coverage with a real browser plugin journey, keep host runtime ownership inside the evaluator, preserve toast context across compact portals, and expose modal content as a compound component.

* fix(plugins): defer native UI presentation runtime

Loading the plugin evaluator initialized the modal and toast presentation stacks even when no plugin requested host UI. Keep the capability stable while delaying platform-heavy presentation code until Modal.Content renders.

* refactor(plugins): move modal metadata to root

Keep Modal.Content body-only while the top-level Modal owns the host-rendered title and leading icon. This leaves presentation metadata stable as the compound API gains future regions.
2026-08-28 11:43:30 +02:00
Desmond ChaiandMohamed Boudra a1ed5a8bad Stop PR status polling from rate-limiting your entire GitHub account (#3825)
* Batch GitHub PR status polls into aliased GraphQL requests

The PR status poller ran one `gh pr view` (GraphQL-backed) plus a
per-PR facts query for every workspace on its own timer, so cost
scaled linearly with workspace count and exhausted the 5,000 pts/hour
GraphQL quota machine-wide past ~50 workspaces (#3587, #2470).

Replace the per-target timers with a global scheduler that snaps poll
targets to a shared 5s grid and flushes all due targets together:

- One aliased discovery query per batch of up to 25 targets (grouped
  by GH host) resolves PR identity, state, mergeability, and the merge
  facts that previously required a second GraphQL call per PR.
- A second aliased query fetches statusCheckRollup contexts only for
  the PRs that matched. Checks for merged/closed PRs are frozen and
  cached by repo#number@headOid, so idle workspaces settle to the
  discovery query alone.
- A failed batch backs off every target in it with one request spent;
  healthy aliases are recovered from the body gh still prints on
  stdout when a single alias errors (ForgeCommandError now carries
  stdout for this).
- Targets whose origin slug/host is still resolving, and fork repos
  whose PR lives in the parent, fall back to the legacy per-target
  path for that cycle. Unowned lookups filter out other forks' PRs
  that share a branch name.

Fixes #3587. Addresses the API budget in #2470.

* fix(server): reserve the shared GitHub polling budget

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-28 11:29:34 +02:00
Christoph LeiterandClaude Opus 5 3d69b0f458 fix(app): render markdown verbatim instead of substituting characters (#3253)
markdown-it's typographer option rewrites characters after parsing, so
a literal (c) came out as ©. Agents label steps and options (a), (b),
(c) all the time, and an ordinary list rendering as (a), (b), © stops
your eye every time.

The same option turns ... into an ellipsis, --- into an em dash, and
straight quotes into curly ones. Agent output is text people paste
back into a shell or a file, so a curly quote in --name="my repo"
looks correct on screen and fails when pasted. The markdown file
preview had the same problem, showing characters that are not in the
file on disk.

Every in-app markdown surface now goes through one factory in
utils/markdown-parser.ts, which leaves typographer off: agent chat,
plan cards, pull request comment bodies and the file preview. The plan
card was rendering through its library's built-in parser because it
never passed a markdownit prop, and that default has typographer on.

Link behaviour is unchanged. The assistant parser keeps its file://
permission and no other surface gains it, and linkify stays on for
chat and the default renderer and off for plan cards, matching what
each did before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:27 +00:00
Christoph LeiterandClaude Opus 5 51a435d3cf feat(app): find workspaces by PR/MR number in the command center (#3008)
* feat(app): find workspaces by PR/MR number in the command center

The command center matched workspaces on title, host, project, and
branch. Typing the change-request number you actually remember found
nothing, so you had to recall the branch name instead.

Workspace results now carry their pull/merge request number, and a
numeric query resolves it directly: 42, #42, !42, pr 42, and mr 42 all
reach the same workspace. Matching compares the parsed number exactly
rather than as a substring, so 42 no longer drags in PR 142 or a branch
that happens to contain those digits. Exact number hits sort above
ordinary text matches for the same query.

Nothing changes visually. The number is a search key only; result rows
render exactly as before.

The number is read from the pull request the daemon already sends.
selectPrHintFromStatus is the fallback only: it derives the number by
parsing the PR/MR URL, which yields nothing for a noncanonical or
absent URL even when the daemon sent a usable number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): exercise change-request parsing through the matcher

parseChangeRequestQuery was exported only so the test could reach it,
which made an implementation helper part of the module surface and let
the parser cases pass without covering how the parser composes with
workspace matching.

Unexport it and drive the same spellings through matchWorkspaceQuery.
The rejection cases now assert what they actually protect: a query like
fix-42-retries must not take the number path, so a workspace whose PR is
42 does not match it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:50:31 +00:00
Mohamed Boudra fb67d2cdc8 Keep the daemon responsive during Git queue pressure (#3945)
* fix(server): keep Git pressure from stalling daemon

Git process starts were refilled synchronously, so slow process spawning could monopolize the main loop long enough to trip connection liveness checks. Admit one process per event-loop turn and expose refresh/fetch provenance in runtime queue metrics.

* perf(server): avoid delaying isolated Git commands

Start the first command immediately when the scheduler is idle. Backlog admission and completion-driven refills still yield between spawns, preserving daemon liveness without adding a turn to normal sequential Git traffic.

* fix(server): keep Git admission outside fake clocks

Subprocess admission is event-loop ordering rather than simulated wall-clock behavior. Use the native Node immediate so service tests can fake debounce timers without deadlocking queued real Git processes.

* refactor(server): bind Git command provenance explicitly

* test(server): expect bound Git fetch runner
2026-08-28 10:25:20 +02:00
Mohamed Boudra fe803f6e70 Let plugins contribute contextual composer pills (#3956)
* feat(plugin): allow composer pill contributions

Keep pill presentation host-owned while plugin components render contextual content. Pill callbacks reuse the selected host's API, RPC, surface, and registered-panel navigation capabilities without exposing router or workspace layout internals.

* fix(plugin): place composer pills in track bar

* fix(plugin): make composer pills client-owned
2026-08-28 16:02:00 +08:00
a501e1e051 feat(sdk): expose a session's commands and full snapshot on the agent handle (#3719)
* feat(sdk): expose a session's own commands on the agent handle

`DaemonClient.listCommands` has always been able to ask a running session
which slash commands and skills it loaded, but `PaseoAgentHandle` never
surfaced it, so SDK and plugin consumers could only see what they could
find on disk. Provider built-ins — the ones bundled inside the agent
binary rather than installed into a scannable directory — were therefore
invisible to them.

Add `agent.commands()` as a thin delegation to the existing RPC. Every
provider that implements `listCommands` (claude, codex, opencode, acp,
omp, pi) answers it, so a consumer no longer needs per-provider
filesystem knowledge to enumerate what a session can run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sdk): expose the observed agent snapshot on the agent handle

The handle mirrored four snapshot fields (workspaceId, cwd, status, id)
and hid the rest behind current(). Add getters for capabilities,
availableModes, pendingPermissions, activeTurn, lastUsage, lastError,
features, runtimeInfo, and archivedAt so callers can read one field
without destructuring the whole snapshot and null-checking it.

Each reads the last observed snapshot and never fetches, matching the
existing getters. null means "not observed yet".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(sdk): clarify nullable snapshot getters

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-28 07:09:20 +00:00
paseo-ai[bot] 1fc5250544 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-27 18:24:46 +00:00
169 changed files with 8288 additions and 912 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 0.7.0-beta.2 - 2026-08-28
### Added
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
### Improved
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
### Fixed
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
## 0.7.0-beta.1 - 2026-08-27
### Changed
+2 -2
View File
@@ -111,8 +111,8 @@ paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
# run on a remote daemon; --cwd is a path on that host
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
+1 -1
View File
@@ -103,7 +103,7 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
+5 -2
View File
@@ -556,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host <host:port>` to point the CLI at a different daemon:
Use `--host` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
npm run cli -- ls -a --host localhost:7777
npm run cli -- ls -a --host ssh://user@host
```
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
+5
View File
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
allowed-failure job carries the `warning` trait, and `canceling` remains active
until GitLab reports a terminal status.
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
targets per host, batch their reads, and stop until GitHub's reset time when
the reserve is exhausted. Never add a per-target GitHub request to the poll
path. Resolve fork PRs through their parent repository without abandoning the
shared batch.
+54 -8
View File
@@ -1,7 +1,7 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
Paseo does not sandbox it.
@@ -121,17 +121,20 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/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>`.
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
@@ -202,6 +205,49 @@ They use typed plugin RPC only for plugin-specific backend work. Navigation is l
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
workspace-layout store access.
## Contribute composer pills
Register a headless client entrypoint, then add and remove targeted pills from that client
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
client state without mounting a panel or surface.
```tsx
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
```
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
the host, or unloading the app tears down the contribution.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
+1 -1
View File
@@ -1 +1 @@
sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE=
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/server": "0.7.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/highlight": "0.7.0-beta.2",
"@getpaseo/plugin": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,270 @@
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
function workspaceRow(page: Page, workspaceId: string): Locator {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function action(panel: Locator, title: string): Locator {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
}
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
const row = workspaceRow(page, workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
// that used to make the sidebar-owned rename dialog unreachable.
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: project.projectDisplayName });
await expect(header).toHaveCount(1, { timeout: 30_000 });
await header.click();
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
// surface under test anyway, and it works from every route.
async function openCommandCenter(page: Page): Promise<Locator> {
await page.keyboard.press("ControlOrMeta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
async function runCommand(page: Page, query: string, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
const entry = action(panel, title);
await expect(entry).toBeVisible({ timeout: 15_000 });
await entry.click();
}
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
const workspaces = await workspace.client.fetchWorkspaces();
for (const entry of workspaces.entries) {
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
}
return undefined;
}
async function rejectNextLabelAssignment(page: Page): Promise<void> {
let rejectNext = true;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
let request: { type?: string; requestId?: string } | null = null;
try {
const envelope = JSON.parse(raw) as {
type?: string;
message?: { type?: string; requestId?: string };
};
request = envelope.type === "session" ? (envelope.message ?? null) : null;
} catch {
server.send(message);
return;
}
if (
rejectNext &&
request?.type === "workspace.label.assignment.set.request" &&
request.requestId
) {
rejectNext = false;
browser.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: "Injected label mutation failure.",
code: "handler_error",
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
}
test.describe("Command center workspace management", () => {
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await collapseProjectSection(page, workspace);
await runCommand(page, "rename", "Rename workspace");
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("copies the workspace path and confirms with a toast", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "copy path", "Copy workspace path");
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
// off a workspace route would give the user two entries that close the palette and do nothing.
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("toggles a label on the active workspace from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
try {
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
// Unassigning the only label drops the field entirely rather than leaving an empty array.
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
const reopened = await openCommandCenter(page);
await reopened.getByTestId("command-center-input").fill("urgent");
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
test("shows an error toast when assigning a label fails", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
try {
await rejectNextLabelAssignment(page);
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
await expect(page.getByTestId("app-toast-message")).toContainText(
"Injected label mutation failure.",
);
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
});
@@ -0,0 +1,37 @@
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The plan card has to pass its own markdown parser. Drop that prop and
// react-native-markdown-display silently supplies one with `typographer: true`
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
// No unit test can catch that, because the defect lives in the JSX.
//
// Nothing here is provider-specific: the mock provider drives it, and the
// behavior is the same for every agent.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "plan-card-markdown-",
title: "Plan card markdown e2e",
initialPrompt: "Emit synthetic plan approval.",
});
try {
await openAgentRoute(page, session);
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
await expect(planCard).not.toContainText("©");
await expect(planCard).not.toContainText("—buzz");
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,151 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function ModalBody({ onSaved }) {
usePaseo();
useQueryClient();
const toast = useToast();
function save() {
toast.show("Issue saved", { variant: "success" });
onSaved();
}
return <View>
<Text>Plugin modal contexts ready</Text>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
</View>;
}
function Surface() {
const [open, setOpen] = useState(false);
return <View>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row" }}>
<Icon name="Pencil" size={18} />
<Text>Open plugin modal</Text>
</View>
</Pressable>
<Modal
title="Edit plugin issue"
icon={<Icon name="Pencil" size={18} />}
open={open}
onOpenChange={setOpen}
>
<Modal.Content>
<ModalBody onSaved={() => setOpen(false)} />
</Modal.Content>
</Modal>
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({
id: "main",
title: "Host UI",
icon: "PanelsTopLeft",
surface: "main",
});
return () => {};
}`;
async function openHostUiPlugin(page: Page): Promise<void> {
await gotoAppShell(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function useNonCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 1100, height: 800 });
}
async function useCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(dialog).not.toBeVisible();
}
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function savePluginIssue(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save issue", exact: true }).click();
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
}
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await useNonCompactLayout(page);
await openHostUiPlugin(page);
await test.step("non-compact layouts use a centered dialog", async () => {
await openPluginModal(page);
await expectCenteredPluginDialog(page);
await closeCenteredPluginDialog(page);
});
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
await useCompactLayout(page);
await reopenHostUiPluginFromCompactSidebar(page);
await openPluginModal(page);
await expectCompactPluginSheet(page);
await savePluginIssue(page);
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { submitMessage } from "../support/helpers/composer";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
@@ -31,7 +32,15 @@ function isSettledWorkspaceUrl(url: URL): boolean {
function pluginSource(): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -54,7 +63,56 @@ function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function ComposerPill({ theme, workspaceId, agentId }) {
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
const agent = useAgent(agentId, (value) => ({ title: value.title }));
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
}
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
let removePill = () => {};
removePill = client.addComposerPill({
id: "review",
title: "Open composer review",
workspaceId: agent.workspaceId,
agentId: agent.id,
Component: ComposerPill,
async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
removePill();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
},
});
pills.set(agent.id, removePill);
});
return () => {
unsubscribe();
for (const removePill of pills.values()) removePill();
pills.clear();
};
}
export default function contribute(plugin) {
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
@@ -64,6 +122,7 @@ export default function contribute(plugin) {
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
plugin.addClientSide(contributeClient);
return () => {};
}`;
}
@@ -207,7 +266,29 @@ test.describe("plugin workspace panels and Command Center", () => {
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await submitMessage(page, "emit 1 agent stream updates");
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText(
"Review Unrelated title update:Plugin panel context agent",
);
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await capture(page, testInfo, "plugin-composer-pill-compact");
await page.getByRole("button", { name: "Open composer review" }).click();
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Opened from composer pill",
);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
await openCompactSidebar(page);
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
@@ -218,7 +299,6 @@ test.describe("plugin workspace panels and Command Center", () => {
).toBeVisible();
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
});
await test.step("removing the active plugin renders the panel unavailable", async () => {
@@ -0,0 +1,36 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectWorkspaceAbsentFromSidebar,
selectWorkspaceInSidebar,
} from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
expect(result.error).toBeNull();
}
test.describe("Workspace archive cache coherence", () => {
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
await archiveWorkspaceOutsideTheApp(workspace);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-row-store";
const STORE_NAME = "rows";
@@ -116,6 +116,23 @@ export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord |
return rows.length > 0 ? assembleCache(rows) : null;
}
export async function waitForWorkspaceInReplicaCache(
page: Page,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const cache = await readReplicaCache(page);
return cache?.hosts?.some((host) =>
host.workspaces.some((workspace) => workspace.id === workspaceId),
);
},
{ timeout: 15_000 },
)
.toBe(true);
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
const rows: ReplicaRowRecord[] = [];
for (const host of value.hosts ?? []) {
@@ -69,6 +69,7 @@ export interface SeedDaemonClient {
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
}>;
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
/**
* Force the daemon to recompute its git snapshot and diff for a checkout,
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
+2
View File
@@ -39,6 +39,7 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
@@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -56,6 +56,7 @@ import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider";
import { filterAndRankWorkspaces } from "./workspace-search";
import {
buildContributionSections,
filterAndRankBuiltInResults,
@@ -121,10 +122,6 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
@@ -158,6 +155,7 @@ function useBuiltInRows(open: boolean): {
project.projectName,
workspace.currentBranch,
]),
changeRequestNumber: workspace.changeRequestNumber,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -208,12 +206,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankBuiltInResults(
rows.workspaces,
query,
workspaceSearchFields,
compareWorkspacesByTitle,
),
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
},
{
id: "agents",
@@ -102,7 +102,14 @@ function autoModeChoice(): CommandCenterContribution {
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
return { kind: "workspace", id, title, subtitle, run: () => undefined };
return {
kind: "workspace",
id,
title,
subtitle,
changeRequestNumber: null,
run: () => undefined,
};
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
@@ -48,6 +48,8 @@ export interface CommandCenterWorkspaceResult {
id: string;
title: string;
subtitle: string;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
run(): void;
}
@@ -9,17 +9,19 @@ import {
History,
Home,
Keyboard,
PanelLeft,
Plus,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
@@ -54,6 +56,9 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
@@ -91,6 +96,10 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
}
function PanelLeftIcon({ size }: CommandCenterIconProps) {
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -106,6 +115,10 @@ export function CommandCenterRootActions() {
// each time host filters are reconciled.
const groupMode = useSidebarViewStore((state) => state.groupMode);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const isCompact = useIsCompactFormFactor();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
@@ -227,6 +240,33 @@ export function CommandCenterRootActions() {
undefined,
},
},
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
// The right sidebar and focus toggles do NOT belong here — their handlers live in
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
// would list entries that silently no-op off a workspace route. They live in
// workspace-contributions.ts instead. That is why the three toggles render in two
// non-adjacent sections; don't "tidy" them back together.
{
id: "toggle-left-sidebar",
group: "actions",
groupRank: 0,
rank: 7,
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
toggleAgentList();
},
presentation: {
kind: "action",
title: t("settings.shortcuts.help.toggleLeftSidebar"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PanelLeftIcon,
shortcutKeys:
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
undefined,
},
},
];
if (shortcutsAvailable) {
@@ -278,6 +318,7 @@ export function CommandCenterRootActions() {
shortcutPlatform,
shortcutsAvailable,
t,
toggleAgentList,
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
@@ -23,9 +23,14 @@ function source(gitActions: GitActions): {
value: WorkspaceCommandCenterSource;
runGitActions: GitAction[];
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
return {
value: {
gitActions,
@@ -62,19 +67,51 @@ function source(gitActions: GitActions): {
moveTabDown: "Move tab down",
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle side panel",
toggleExplorerSidebar: "Toggle Explorer sidebar",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
pin: "Pin to top",
unpin: "Unpin",
showSetup: "Show setup",
labelsGroup: "Labels",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
capabilities: {
canSplitPanes: true,
canOpenBrowserTabs: true,
isGit: false,
canPin: false,
canShowSetup: false,
},
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
currentBranch: null,
isPinned: false,
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
copyPath: () => {
counters.copiedPaths += 1;
},
copyBranchName: () => {
counters.copiedBranchNames += 1;
},
toggleLabel: (name, assigned) => {
toggledLabels.push({ name, assigned });
},
},
runGitActions,
dispatched,
toggledLabels,
get copiedPaths() {
return counters.copiedPaths;
},
get copiedBranchNames() {
return counters.copiedBranchNames;
},
};
}
@@ -147,6 +184,8 @@ describe("workspace command center contributions", () => {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
canPin: false,
canShowSetup: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
@@ -155,6 +194,8 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -164,7 +205,14 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
// workspace:rename dispatches to workspace scope
expect(
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
).toBe(true);
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
expect(
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -179,4 +227,164 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
it("dispatches rename to the workspace scope", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const rename = contributions.find((item) => item.id === "workspace:rename");
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
rename?.run();
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
});
it("copies the workspace path without going through the dispatcher", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:copy-path")?.run();
expect(fixture.copiedPaths).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
(item) => item.id === "workspace:copy-branch-name",
),
).toBe(false);
const withBranch = source({ primary: null, secondary: [], menu: [] });
withBranch.value.currentBranch = "feature/login";
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
(item) => item.id === "workspace:copy-branch-name",
);
expect(contribution?.keywords).toContain("feature/login");
contribution?.run();
expect(withBranch.copiedBranchNames).toBe(1);
});
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
const unpinned = source({ primary: null, secondary: [], menu: [] });
unpinned.value.capabilities.canPin = true;
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
(item) => item.id === "workspace:pin",
);
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
expect(pinContribution?.visibility).toBe("always");
pinContribution?.run();
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
const pinned = source({ primary: null, secondary: [], menu: [] });
pinned.value.capabilities.canPin = true;
pinned.value.isPinned = true;
expect(
buildWorkspaceCommandCenterContributions(pinned.value).find(
(item) => item.id === "workspace:pin",
)?.presentation,
).toMatchObject({ title: "Unpin" });
const unsupported = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(unsupported.value).some(
(item) => item.id === "workspace:pin",
),
).toBe(false);
});
it("lists Show setup only when the workspace has setup to show", () => {
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
(item) => item.id === "workspace:show-setup",
),
).toBe(false);
const withSetup = source({ primary: null, secondary: [], menu: [] });
withSetup.value.capabilities.canShowSetup = true;
buildWorkspaceCommandCenterContributions(withSetup.value)
.find((item) => item.id === "workspace:show-setup")
?.run();
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
});
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
// root set, where they would silently no-op off a workspace route.
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = false;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
expect(fixture.dispatched).toEqual([
{ id: "sidebar.toggle.right", scope: "sidebar" },
{ id: "workspace.focus.toggle", scope: "workspace" },
]);
for (const contribution of contributions) {
expect(contribution.group).toBe("workspace");
}
});
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
// panes are available, so the standalone entry must step aside there — otherwise the palette
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
it("omits the standalone focus toggle when the pane set already covers it", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const focusToggles = contributions.filter(
(item) =>
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
);
expect(focusToggles).toHaveLength(1);
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
});
it("omits the labels group when the catalog hasn't loaded", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
});
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.labelCatalog = [
{ name: "bug", assigned: true },
{ name: "urgent", assigned: false },
];
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const bug = contributions.find((item) => item.id === "workspace:label:bug");
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
expect(bug?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "bug"],
selected: true,
});
expect(urgent?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "urgent"],
selected: false,
});
bug?.run();
urgent?.run();
expect(fixture.toggledLabels).toEqual([
{ name: "bug", assigned: false },
{ name: "urgent", assigned: true },
]);
});
});
@@ -8,6 +8,13 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface WorkspaceCommandCenterLabelChoice {
name: string;
/** Whether the current workspace already carries this label. */
assigned: boolean;
icon?: CommandCenterIcon;
}
export interface WorkspaceCommandCenterLabels {
section: string;
newAgent: string;
@@ -42,6 +49,14 @@ export interface WorkspaceCommandCenterLabels {
closePane: string;
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
rename: string;
copyPath: string;
copyBranchName: string;
pin: string;
unpin: string;
showSetup: string;
labelsGroup: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -63,6 +78,14 @@ export interface WorkspaceCommandCenterIcons {
moveTab?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
unpin?: CommandCenterIcon;
showSetup?: CommandCenterIcon;
toggleFocusMode?: CommandCenterIcon;
label?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts {
closePane?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
pinWorkspace?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -89,12 +113,27 @@ export interface WorkspaceCommandCenterSource {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
/** Host supports the `workspacePinning` feature. */
canPin: boolean;
/** The workspace has setup commands or a setup error — same gate as the header menu. */
canShowSetup: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
currentBranch: string | null;
isPinned: boolean;
/**
* The host's label catalog, each entry told whether the current workspace carries it. Null
* before the catalog has loaded — omits the whole group rather than showing it empty.
*/
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
}
function buildGitContribution(
@@ -459,6 +498,35 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildWorkspaceCallback(input: {
source: WorkspaceCommandCenterSource;
id: string;
rank: number;
title: string;
keywords: readonly string[];
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
run: () => void;
visibility: "always" | "query";
}): CommandCenterContribution {
return {
id: input.id,
group: "workspace",
groupRank: -1,
rank: input.rank,
keywords: input.keywords,
visibility: input.visibility,
run: input.run,
presentation: {
kind: "action",
title: input.title,
sectionTitle: input.source.labels.section,
icon: input.icon,
shortcutKeys: input.shortcutKeys,
},
};
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
@@ -511,20 +579,150 @@ export function buildWorkspaceCommandCenterContributions(
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
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"));
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:pin",
rank: 20,
title: source.isPinned ? source.labels.unpin : source.labels.pin,
keywords: ["pin", "unpin", "favorite", "sticky"],
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
shortcutKeys: source.shortcuts.pinWorkspace,
action: { id: "workspace.pin", scope: "sidebar" },
visibility: "always",
}),
);
}
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:rename",
rank: 21,
title: source.labels.rename,
keywords: ["rename", "title", "name", "label"],
icon: source.icons.rename,
action: { id: "workspace.rename", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceCallback({
source,
id: "workspace:copy-path",
rank: 22,
title: source.labels.copyPath,
keywords: ["copy", "path", "directory", "folder", "cwd"],
icon: source.icons.copyPath,
run: source.copyPath,
visibility: "query",
}),
);
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
if (source.currentBranch) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:copy-branch-name",
rank: 23,
title: source.labels.copyBranchName,
keywords: ["copy", "branch", "git", source.currentBranch],
icon: source.icons.copyBranchName,
run: source.copyBranchName,
visibility: "query",
}),
);
}
if (source.capabilities.canShowSetup) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:show-setup",
rank: 24,
title: source.labels.showSetup,
keywords: ["setup", "provision", "install", "bootstrap"],
icon: source.icons.showSetup,
action: { id: "workspace.setup.show", scope: "workspace" },
visibility: "query",
}),
);
}
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
// global registration would list two entries that silently no-op on /settings, /sessions,
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-explorer-sidebar",
rank: 25,
title: source.labels.toggleExplorerSidebar,
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "sidebar" },
visibility: "query",
}),
);
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
if (!source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-focus-mode",
rank: 26,
title: source.labels.toggleFocusMode,
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
icon: source.icons.toggleFocusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
visibility: "query",
}),
);
}
contributions.push(...buildLabelContributions(source));
return contributions;
}
/**
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
*/
function buildLabelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (!source.labelCatalog) return [];
return source.labelCatalog.map((choice, index) => ({
id: `workspace:label:${choice.name}`,
group: "workspace",
groupRank: -1,
rank: 30 + index,
keywords: ["label", "tag", choice.name],
visibility: "query" as const,
run: () => source.toggleLabel(choice.name, !choice.assigned),
presentation: {
kind: "choice" as const,
path: [source.labels.labelsGroup, choice.name] as const,
icon: choice.icon,
selected: choice.assigned,
testId: `command-center-workspace-label-${choice.name}`,
},
}));
}
@@ -1,4 +1,4 @@
import { useMemo, type ReactElement } from "react";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownToLine,
@@ -8,12 +8,16 @@ import {
Copy,
Files,
Focus,
GitBranch,
GitCompareArrows,
GitPullRequest,
Globe,
ListChecks,
Move,
PanelRight,
Pencil,
Pin,
PinOff,
RotateCw,
Rows2,
SquarePen,
@@ -25,23 +29,35 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useHostFeature } from "@/runtime/host-features";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
buildWorkspaceLabelPickerRows,
useWorkspaceLabelProjection,
workspaceLabelErrorMessage,
workspaceLabels,
} from "@/workspace-labels";
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { getCommandCenterIcon } from "./icon";
import type { CommandCenterIcon } from "./contributions";
import { useCommandCenterActions } from "./provider";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterLabelChoice,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
@@ -65,6 +81,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
moveTab: getCommandCenterIcon(Move),
focusMode: getCommandCenterIcon(ArrowDownToLine),
explorerSidebar: getCommandCenterIcon(PanelRight),
// Workspace management action icons
copyPath: getCommandCenterIcon(Copy),
copyBranchName: getCommandCenterIcon(GitBranch),
pin: getCommandCenterIcon(Pin),
unpin: getCommandCenterIcon(PinOff),
showSetup: getCommandCenterIcon(ListChecks),
toggleFocusMode: getCommandCenterIcon(Focus),
};
const OPEN_PANEL_LABEL_KEYS = {
@@ -86,6 +109,58 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
/**
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
* the sidebar's own picker.
*/
function useWorkspaceLabelCatalog(
serverId: string | null,
fields: { id: string; labels: readonly string[] } | null,
): {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
toggleLabel: (name: string, assigned: boolean) => void;
} {
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
serverId ?? undefined,
);
const toast = useToast();
const assignedLabels = fields?.labels;
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
() =>
labelHost?.status === "online" && assignedLabels
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
(row) => ({
name: row.name,
assigned: row.assigned,
icon: getLabelCommandCenterIcon(row.color),
}),
)
: null,
[assignedLabels, labelDefinitions, labelHost?.status],
);
const toggleLabel = useCallback(
async (name: string, assigned: boolean) => {
if (!serverId || !fields) return;
const definition = labelDefinitions.find((label) => label.name === name);
if (!definition) return;
try {
await workspaceLabels.setAssignment({
serverId,
workspaceId: fields.id,
label: definition,
assigned,
});
} catch (cause) {
toast.error(workspaceLabelErrorMessage(cause));
}
},
[fields, labelDefinitions, serverId, toast],
);
return { labelCatalog, toggleLabel };
}
export function useWorkspaceCommandCenterActions(): void {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -104,8 +179,29 @@ export function useWorkspaceCommandCenterActions(): void {
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
// One narrow projection for the workspace management contribution fields. The registry's snapshot
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const currentBranch = fields?.currentBranch ?? null;
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
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({
serverId: serverId ?? "",
@@ -113,6 +209,27 @@ export function useWorkspaceCommandCenterActions(): void {
icons: GIT_ACTION_ICONS,
});
const runGitAction = useGitActionRunner();
const clipboard = useWorkspaceClipboardActions();
const copyPath = useCallback(() => {
if (!fields) return;
clipboard.copyPath({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const copyBranchName = useCallback(() => {
if (!fields) return;
clipboard.copyBranchName({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
const actions = useMemo(
() =>
@@ -152,6 +269,14 @@ export function useWorkspaceCommandCenterActions(): void {
closePane: t("settings.shortcuts.help.closePane"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
pin: t("sidebar.workspace.actions.pin"),
unpin: t("sidebar.workspace.actions.unpin"),
showSetup: t("workspace.header.actions.showSetup"),
labelsGroup: t("workspaceLabels.title"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -162,27 +287,43 @@ export function useWorkspaceCommandCenterActions(): void {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
canPin,
canShowSetup,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
currentBranch,
isPinned,
labelCatalog,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
activeTabKind,
canPin,
canShowSetup,
copyBranchName,
copyPath,
currentBranch,
focusedTabs.length,
gitActions,
isCompact,
isGit,
isPinned,
keyboardActionDispatcher,
labelCatalog,
overrides,
runGitAction,
t,
toggleLabel,
],
);
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterWorkspaceResult } from "./results";
import { filterAndRankWorkspaces } from "./workspace-search";
function workspace(input: {
id: string;
title?: string;
subtitle?: string;
changeRequestNumber?: number | null;
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: input.id,
title: input.title ?? input.id,
subtitle: input.subtitle ?? "",
changeRequestNumber: input.changeRequestNumber ?? null,
run: () => undefined,
};
}
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
(row) => row.id,
);
}
describe("filterAndRankWorkspaces", () => {
const subject = workspace({
id: "payments",
title: "Payments refactor",
subtitle: "Primary host · feature/checkout",
changeRequestNumber: 42,
});
it("matches everything when the query is empty", () => {
expect(ids([subject], " ")).toEqual(["payments"]);
});
it("matches its own change-request number in every spelling", () => {
const spellings = ["42", " 42 ", "#42", "!42", "pr 42", "mr 42", "PR #42", "mr!42", "pr42"];
for (const query of spellings) {
expect(ids([subject], query)).toEqual(["payments"]);
}
});
it("does not take the number path for text that merely contains digits", () => {
const numbered = workspace({ id: "unrelated workspace", changeRequestNumber: 42 });
for (const query of ["fix-42-retries", "42x", "v4.2", "pr"]) {
expect(ids([numbered], query)).toEqual([]);
}
});
it("does not match a different change-request number that shares digits", () => {
const other = workspace({ id: "unrelated", changeRequestNumber: 142 });
expect(ids([other], "42")).toEqual([]);
});
it("still matches on title and branch text", () => {
expect(ids([subject], "checkout")).toEqual(["payments"]);
expect(ids([subject], "payments")).toEqual(["payments"]);
});
it("leaves a workspace without a change request unaffected", () => {
const noPr = workspace({ id: "docs workspace" });
expect(ids([noPr], "42")).toEqual([]);
expect(ids([noPr], "docs")).toEqual(["docs workspace"]);
});
it("falls back to text matching when the number does not match", () => {
const textual = workspace({
id: "text",
subtitle: "branch fix-42-retries",
changeRequestNumber: 7,
});
expect(ids([textual], "42")).toEqual(["text"]);
});
it("ranks an exact change request above an ordinary text match", () => {
const text = workspace({ id: "a-text", title: "Migration 42" });
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
});
});
@@ -0,0 +1,70 @@
import {
compareCommandCenterScores,
filterAndRankBuiltInResults,
scoreSearchFields,
type CommandCenterScore,
type CommandCenterWorkspaceResult,
} from "./results";
/** A change-request query: an optional `pr`/`mr` word, an optional `#`/`!`, a number. */
const CHANGE_REQUEST_QUERY = /^(?:(?:pr|mr)\s*)?[#!]?(\d+)$/i;
/**
* Parse a change-request-shaped query into its number.
*
* Accepts `42`, `#42`, `!42`, `pr 42`, `pr42`, `PR #42`, `mr!42`, and so on.
* Returns null for anything else, including text queries that merely contain
* digits (`fix-42-retries`), so those fall through to normal text matching.
*/
function parseChangeRequestQuery(query: string): number | null {
const match = CHANGE_REQUEST_QUERY.exec(query.trim());
if (!match) return null;
const number = Number.parseInt(match[1], 10);
return Number.isSafeInteger(number) ? number : null;
}
interface ScoredWorkspace {
workspace: CommandCenterWorkspaceResult;
score: CommandCenterScore | null;
changeRequestHit: boolean;
}
function searchFields(workspace: CommandCenterWorkspaceResult) {
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
}
function compareScoredWorkspaces(
left: ScoredWorkspace,
right: ScoredWorkspace,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): number {
if (left.changeRequestHit !== right.changeRequestHit) {
return left.changeRequestHit ? -1 : 1;
}
if (left.score && right.score) {
const scoreDelta = compareCommandCenterScores(left.score, right.score);
if (scoreDelta !== 0) return scoreDelta;
}
return tiebreak(left.workspace, right.workspace);
}
export function filterAndRankWorkspaces(
workspaces: readonly CommandCenterWorkspaceResult[],
query: string,
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
): CommandCenterWorkspaceResult[] {
if (!query.trim()) {
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
}
const changeRequestNumber = parseChangeRequestQuery(query);
const matches: ScoredWorkspace[] = [];
for (const workspace of workspaces) {
const score = scoreSearchFields(query, searchFields(workspace));
const changeRequestHit =
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
}
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
return matches.map((match) => match.workspace);
}
@@ -31,7 +31,10 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined,
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
// Workspace management shortcuts
pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined,
};
}
@@ -23,6 +23,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
@@ -450,6 +451,8 @@ export interface AdaptiveModalSheetProps {
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
contextBridge?: ContextBridge | null;
}
export function AdaptiveModalSheet({
@@ -467,6 +470,7 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
sizeContentToCurrentSnapPoint = false,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -634,7 +638,7 @@ export function AdaptiveModalSheet({
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
contextBridge={contextBridge}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
@@ -1,11 +1,19 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
import type { Theme } from "@/styles/theme";
const ThemedQrCode = withUnistyles(QrCode);
const ThemedLink2 = withUnistyles(Link2);
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
const ThemedTerminal = withUnistyles(Terminal);
const foregroundIconMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const styles = StyleSheet.create((theme) => ({
option: {
@@ -37,6 +45,7 @@ export interface AddHostMethodModalProps {
visible: boolean;
onClose: () => void;
onDirectConnection: () => void;
onRemoteSsh: () => void;
onScanQr: () => void;
onPasteLink: () => void;
}
@@ -45,10 +54,10 @@ export function AddHostMethodModal({
visible,
onClose,
onDirectConnection,
onRemoteSsh,
onScanQr,
onPasteLink,
}: AddHostMethodModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
@@ -60,6 +69,10 @@ export function AddHostMethodModal({
onScanQr();
}, [onScanQr]);
const handleRemoteSsh = useCallback(() => {
onRemoteSsh();
}, [onRemoteSsh]);
const handlePaste = useCallback(() => {
onPasteLink();
}, [onPasteLink]);
@@ -78,7 +91,7 @@ export function AddHostMethodModal({
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<ThemedLink2 size={18} uniProps={foregroundIconMapping} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -87,6 +100,24 @@ export function AddHostMethodModal({
</View>
</Pressable>
{isElectronRuntime() ? (
<Pressable
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
accessibilityLabel={t("pairing.connectionMethods.remoteSsh.title")}
testID="add-host-method-remote-ssh"
>
<ThemedTerminal size={18} uniProps={foregroundIconMapping} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.remoteSsh.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</Pressable>
) : null}
{isNative && !isFdroidBuild ? (
<Pressable
style={styles.option}
@@ -94,7 +125,7 @@ export function AddHostMethodModal({
accessibilityRole="button"
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
>
<QrCode size={18} color={theme.colors.foreground} />
<ThemedQrCode size={18} uniProps={foregroundIconMapping} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -111,7 +142,7 @@ export function AddHostMethodModal({
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<ThemedClipboardPaste size={18} uniProps={foregroundIconMapping} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -0,0 +1,172 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Terminal } from "lucide-react-native";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
}));
export interface AddRemoteSshHostModalProps {
visible: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddRemoteSshHostModal({
visible,
onClose,
onCancel,
onSaved,
}: AddRemoteSshHostModalProps) {
const { t } = useTranslation();
const hosts = useHosts();
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
const clear = useCallback(() => {
targetRef.current = "";
inputRef.current?.replaceText("");
setErrorMessage("");
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
clear();
onClose();
}, [clear, isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
clear();
(onCancel ?? onClose)();
}, [clear, isSaving, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const rawTarget = targetRef.current.trim();
if (!rawTarget) {
setErrorMessage(t("pairing.remoteSsh.errors.targetRequired"));
return;
}
let target: ReturnType<typeof parseSshTransportUri>;
try {
target = parseSshTransportUri(rawTarget);
} catch {
setErrorMessage(t("pairing.remoteSsh.errors.invalidTarget"));
return;
}
let result: Awaited<ReturnType<typeof probeAndUpsertRemoteSshConnection>>;
try {
setIsSaving(true);
setErrorMessage("");
result = await probeAndUpsertRemoteSshConnection(target);
} catch (error) {
const message =
error instanceof DaemonConnectionTestError
? t("pairing.remoteSsh.errors.failedToConnect", { detail: error.message })
: t("common.errors.unableToSave");
setErrorMessage(message);
return;
} finally {
setIsSaving(false);
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
}, []);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
testID="add-remote-ssh-host-modal"
>
<Text style={styles.helper}>{t("pairing.remoteSsh.helper")}</Text>
<Field
label={t("pairing.remoteSsh.fields.target")}
error={errorMessage}
testID="remote-ssh-target"
>
<FormTextInput
ref={inputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-target-input"
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
initialValue=""
onChangeText={handleTargetChange}
placeholder="ssh://user@host"
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</Field>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
>
{t("pairing.remoteSsh.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
onPress={handleSubmit}
disabled={isSaving}
leftIcon={ThemedTerminal}
testID="remote-ssh-submit"
>
{isSaving
? t("pairing.remoteSsh.actions.connecting")
: t("pairing.remoteSsh.actions.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
@@ -28,6 +28,7 @@ import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-t
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { createMarkdownParser } from "@/utils/markdown-parser";
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -65,7 +66,9 @@ function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRe
return { style: createCompactMarkdownStyles(theme) };
}
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
// Serves PR comment bodies and the markdown file preview; agent chat passes its
// own parser. The preview has to show the bytes on disk, so no typographer.
const defaultMarkdownParser = createMarkdownParser({ linkify: true });
const EMPTY_TEXT_STYLE: TextStyle = {};
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
export interface MarkdownRendererProps {
+7 -1
View File
@@ -5,6 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { createMarkdownParser } from "@/utils/markdown-parser";
// Without this prop react-native-markdown-display builds its own parser with
// `typographer: true`, which would render a plan's literal `(c)` as ©. Its
// default also leaves linkify off, so this one keeps bare URLs as plain text.
const planMarkdownParser = createMarkdownParser({ linkify: false });
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
@@ -219,7 +225,7 @@ export function PlanCard({
<View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{resolvedTitle}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>
<Markdown style={markdownStyles} rules={markdownRules} markdownit={planMarkdownParser}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
@@ -9,8 +9,6 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
memo,
useCallback,
@@ -36,7 +34,8 @@ import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -135,7 +134,6 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -1263,42 +1261,14 @@ function WorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -1308,13 +1278,6 @@ function WorkspaceRowWithMenu({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -1373,14 +1336,10 @@ function WorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -40,14 +40,11 @@ import {
CircleX,
} from "lucide-react-native";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -626,42 +623,17 @@ function StatusWorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -719,14 +691,10 @@ function StatusWorkspaceRowWithMenu({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -2,23 +2,20 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
@@ -108,42 +105,14 @@ export function SidebarWorkspaceRow({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -153,13 +122,6 @@ export function SidebarWorkspaceRow({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
@@ -206,14 +168,10 @@ export function SidebarWorkspaceRow({
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
+31 -2
View File
@@ -3,11 +3,19 @@ import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import {
QrCode,
Link2,
ClipboardPaste,
ExternalLink,
Settings,
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
@@ -17,9 +25,10 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isFdroidBuild } from "@/constants/build-profile";
import { isWeb, isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
interface WelcomeAction {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
key: "scan-qr" | "direct-connection" | "remote-ssh" | "paste-pairing-link";
label: string;
testID: string;
primary: boolean;
@@ -165,6 +174,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -188,6 +198,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
@@ -249,6 +261,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
if (isElectronRuntime()) {
actions.splice(1, 0, {
key: "remote-ssh",
label: t("pairing.connectionMethods.remoteSsh.title"),
testID: "welcome-remote-ssh",
primary: false,
icon: Terminal,
onPress: handleOpenRemoteSsh,
});
}
const scrollContentContainerStyle = useMemo(
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
[theme.spacing, insets.bottom],
@@ -300,6 +323,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
const WORKSPACE_RENAME_ACTIONS: readonly KeyboardActionId[] = ["workspace.rename"];
/**
* The rename dialog used to live only on the sidebar row, so it was unreachable whenever that row
* was not rendered — a collapsed project or status group, a collapsed Pinned section, or focus
* mode. This is the same fix `use-global-workspace-pin-action.ts` applies to pin: one registration
* keyed on the active route selection.
*
* Sidebar rows keep their own modal instances. They rename *their* workspace, not the active one.
*/
export function WorkspaceRenameHost() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const routeWorkspaceId = selection?.workspaceId ?? null;
// Narrow projection so the dialog doesn't re-render on every gitRuntime/diffStat tick.
// `id` is projected rather than reusing the route id: the route carries an opaque workspace id
// that is not guaranteed to equal the descriptor id, and setWorkspaceTitle needs the descriptor.
const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({
id: workspace.id,
name: workspace.name,
title: workspace.title ?? null,
}));
const [isOpen, setIsOpen] = useState(false);
const openFrameRef = useRef<number | null>(null);
const cancelPendingOpen = useCallback(() => {
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current);
openFrameRef.current = null;
}
}, []);
useEffect(() => cancelPendingOpen, [cancelPendingOpen]);
// The command center closes and dispatches in the same React batch, so opening synchronously
// would mount this modal while the palette is still unmounting — on Android the portal stacking
// lands the dialog behind it, and on web the palette's teardown steals focus back from the
// input. Wait a frame. (The caller has already cleared the focus-restore element.)
const handle = useCallback(() => {
if (!fields) return false;
cancelPendingOpen();
openFrameRef.current = requestAnimationFrame(() => {
openFrameRef.current = null;
setIsOpen(true);
});
return true;
}, [cancelPendingOpen, fields]);
const handleClose = useCallback(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen]);
// Closing on workspace change avoids renaming whatever the user navigated to instead.
useEffect(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen, serverId, routeWorkspaceId]);
useKeyboardActionHandler({
handlerId: "workspace-rename-global",
actions: WORKSPACE_RENAME_ACTIONS,
enabled: serverId !== null && fields !== null,
priority: 0,
handle,
});
const workspace = useMemo(
() =>
serverId && fields
? { serverId, workspaceId: fields.id, name: fields.name, title: fields.title }
: null,
[fields, serverId],
);
if (!workspace) return null;
return (
<WorkspaceRenameModal
visible={isOpen}
workspace={workspace}
onClose={handleClose}
testID="workspace-rename-modal-global"
/>
);
}
@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. The sidebar callers must keep passing
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
* builds its locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const renameAsync = renameMutation.mutateAsync;
const handleSubmit = useCallback(
async (value: string) => {
await renameAsync(value.trim());
},
[renameAsync],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={handleSubmit}
testID={testID}
/>
);
}
+5 -5
View File
@@ -19,12 +19,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
/**
* The strip of pills where a pane's ambient trackers live — subagents and tasks today.
* The strip of pills where a pane's ambient trackers and plugin actions live.
*
* Everything in it is a pill: a count you can read without opening anything, and a panel behind
* it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer
* further down the pane. The bar has at most one subagent pill and one task pill, so it remains
* one line and has deterministic geometry on every platform.
* Trackers expose a count and a detail panel; plugin actions expose their own icon and text.
* Trackers used to be stacked cards, so every one of them pushed the composer further down the
* pane. Built-in and contributed pills share this one-line rail and its deterministic geometry
* on every platform.
*
* The bar floats over the transcript with no background, so content remains visible underneath.
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ToastApi } from "@/components/toast-host";
const ToastContext = createContext<ToastApi | null>(null);
export function useToast(): ToastApi {
const value = useContext(ToastContext);
if (!value) {
throw new Error("useToast must be used within ToastProvider");
}
return value;
}
export function ToastApiProvider({ api, children }: { api: ToastApi; children: ReactNode }) {
return <ToastContext.Provider value={api}>{children}</ToastContext.Provider>;
}
+6 -13
View File
@@ -1,23 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
import type { ReactNode } from "react";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ToastApiProvider } from "./toast-api-context";
const ToastContext = createContext<ToastApi | null>(null);
export function useToast(): ToastApi {
const value = useContext(ToastContext);
if (!value) {
throw new Error("useToast must be used within ToastProvider");
}
return value;
}
export { ToastApiProvider, useToast } from "./toast-api-context";
export function ToastProvider({ children }: { children: ReactNode }) {
const { api, toast, dismiss } = useToastHost();
return (
<ToastContext.Provider value={api}>
<ToastApiProvider api={api}>
{children}
<ToastViewport toast={toast} onDismiss={dismiss} />
</ToastContext.Provider>
</ToastApiProvider>
);
}
@@ -1,13 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
import {
buildDesktopDaemonTransportUrl,
createDesktopDaemonTransportFactory,
} from "./desktop-daemon-transport";
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
const LOCAL_URL = "paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock";
describe("desktop-daemon-transport", () => {
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
it("uses the main-process event as readiness when it races registration", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
const cleanup = vi.fn();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
@@ -15,32 +19,93 @@ describe("desktop-daemon-transport", () => {
const onOpen = vi.fn();
transport.onOpen(onOpen);
rpc.emitEvent({ sessionId: "local-session-1", kind: "open" });
expect(onOpen).not.toHaveBeenCalled();
rpc.resolveListen(cleanup);
await Promise.resolve();
rpc.resolveOpen("local-session-1");
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
expect(sessionId).not.toBe("");
rpc.emitEvent({ sessionId, kind: "open" });
expect(onOpen).toHaveBeenCalledTimes(1);
rpc.resolveRegistration();
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
it("does not start a session when listener setup finishes after close", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const cleanup = vi.fn();
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
transport.close();
rpc.resolveOpen("local-session-2");
rpc.resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(rpc.closedSessions).toEqual(["local-session-2"]);
expect(rpc.openCalls).toHaveLength(0);
expect(rpc.closedSessions).toHaveLength(1);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("cancels a registered session while readiness is pending", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
rpc.resolveListen(vi.fn());
await Promise.resolve();
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
expect(sessionId).not.toBe("");
transport.close();
expect(rpc.closedSessions).toEqual([sessionId]);
});
it("passes Remote SSH parameters to the desktop transport bridge", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const url = buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
transportFactory!({ url });
rpc.resolveListen(vi.fn());
await Promise.resolve();
expect(rpc.openCalls).toHaveLength(1);
expect(rpc.openCalls[0]?.target).toEqual({
transportType: "ssh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
});
it.each([0, 65536])("rejects an out-of-range Remote SSH port (%s)", (sshPort) => {
const transportFactory = createDesktopDaemonTransportFactory(
createFakeLocalDaemonTransportRpc(),
);
expect(transportFactory).not.toBeNull();
const url = buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: "deploy@example.com",
sshPort,
});
expect(() => transportFactory!({ url })).toThrow("Invalid SSH transport target");
});
});
@@ -2,13 +2,15 @@ import type {
DaemonTransport,
DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client";
import type { LocalTransportTarget } from "./desktop-daemon";
import { validatePort, validateSshHost } from "@getpaseo/protocol/ssh-transport";
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
import {
defaultLocalDaemonTransportRpc,
type LocalDaemonTransportEvent,
type LocalDaemonTransportRpc,
} from "./local-daemon-transport-rpc";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
const DESKTOP_TRANSPORT_SCHEME = "paseo+desktop:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
@@ -28,21 +30,32 @@ function decodeBase64ToBytes(base64: string): Uint8Array {
return bytes;
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
export function buildDesktopDaemonTransportUrl(target: DesktopDaemonTransportTarget): string {
const url = new URL(`${DESKTOP_TRANSPORT_SCHEME}//${target.transportType}`);
if (target.transportType === "ssh") {
url.searchParams.set("host", target.host);
if (target.sshPort !== undefined) {
url.searchParams.set("port", String(target.sshPort));
}
if (target.daemonPort !== undefined) {
url.searchParams.set("daemonPort", String(target.daemonPort));
}
} else {
url.searchParams.set("path", target.transportPath);
}
return url.toString();
}
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
if (parsed.protocol !== DESKTOP_TRANSPORT_SCHEME) {
throw new Error(`Unsupported desktop transport URL: ${url}`);
}
const transportType = parsed.hostname;
if (transportType === "ssh") return parseSshDesktopTransportUrl(parsed, url);
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid local transport target: ${url}`);
throw new Error(`Invalid desktop transport target: ${url}`);
}
return {
transportType,
@@ -50,12 +63,33 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
export function createDesktopLocalDaemonTransportFactory(
function parseSshDesktopTransportUrl(parsed: URL, rawUrl: string): DesktopDaemonTransportTarget {
try {
const host = validateSshHost(parsed.searchParams.get("host") ?? "");
const sshPort = parseOptionalUrlPort(parsed, "port", "SSH port");
const daemonPort = parseOptionalUrlPort(parsed, "daemonPort", "Daemon port");
return {
transportType: "ssh",
host,
...(sshPort !== undefined ? { sshPort } : {}),
...(daemonPort !== undefined ? { daemonPort } : {}),
};
} catch (error) {
throw new Error(`Invalid SSH transport target: ${rawUrl}`, { cause: error });
}
}
function parseOptionalUrlPort(parsed: URL, key: string, label: string): number | undefined {
const value = parsed.searchParams.get(key);
return value === null ? undefined : validatePort(value, label);
}
export function createDesktopDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
const target = parseDesktopDaemonTransportUrl(url);
const sessionId = `local-session-${globalThis.crypto.randomUUID()}`;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = false;
@@ -80,6 +114,9 @@ export function createDesktopLocalDaemonTransportFactory(
}
};
const emitError = (event?: unknown) => {
if (disposed) {
return;
}
for (const handler of errorHandlers) {
handler(event);
}
@@ -90,61 +127,49 @@ export function createDesktopLocalDaemonTransportFactory(
}
};
void rpc
.listenToEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
const handleEvent = (payload: LocalDaemonTransportEvent) => {
if (disposed || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage(payload.text, false);
return;
}
if (payload.kind === "open") {
emitOpen();
return;
if (payload.binaryBase64) {
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage(payload.text, false);
return;
}
if (payload.binaryBase64) {
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
};
void (async () => {
try {
const cleanup = await rpc.listenToEvents(handleEvent);
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
return;
})
.catch((error) => {
emitError(error);
});
void rpc
.openSession(target)
.then((id) => {
if (disposed) {
void rpc.closeSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
return;
})
.catch((error) => {
await rpc.openSession({ sessionId, target });
} catch (error) {
emitError(error);
});
}
})();
const transport: DaemonTransport = {
send: (data) => {
if (!sessionId) {
if (!didEmitOpen) {
return;
}
if (typeof data === "string") {
@@ -157,12 +182,11 @@ export function createDesktopLocalDaemonTransportFactory(
void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error));
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void rpc.closeSession(currentSessionId).catch((error) => emitError(error));
if (disposed) {
return;
}
disposed = true;
void rpc.closeSession(sessionId).catch((error) => emitError(error));
unlisten?.();
unlisten = null;
},
@@ -40,6 +40,22 @@ export interface LocalTransportTarget {
transportPath: string;
}
export interface RemoteSshTransportTarget {
[key: string]: unknown;
transportType: "ssh";
host: string;
sshPort?: number;
daemonPort?: number;
}
export type DesktopDaemonTransportTarget = LocalTransportTarget | RemoteSshTransportTarget;
export interface OpenLocalTransportSessionInput {
[key: string]: unknown;
sessionId: string;
target: DesktopDaemonTransportTarget;
}
interface LocalTransportEventPayload {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -179,12 +195,10 @@ export async function listenToLocalTransportEvents(
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
}
return raw;
export async function openLocalTransportSession(
input: OpenLocalTransportSessionInput,
): Promise<void> {
await invokeDesktopCommand("open_local_daemon_transport", input);
}
export async function sendLocalTransportMessage(input: {
@@ -3,7 +3,7 @@ import {
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
type OpenLocalTransportSessionInput,
} from "./desktop-daemon";
export interface LocalDaemonTransportEvent {
@@ -17,7 +17,7 @@ export interface LocalDaemonTransportEvent {
}
export interface LocalDaemonTransportRpc {
openSession(target: LocalTransportTarget): Promise<string>;
openSession(input: OpenLocalTransportSessionInput): Promise<void>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>;
@@ -1,4 +1,4 @@
import type { LocalTransportTarget } from "./desktop-daemon";
import type { OpenLocalTransportSessionInput } from "./desktop-daemon";
import type {
LocalDaemonTransportEvent,
LocalDaemonTransportRpc,
@@ -11,10 +11,10 @@ export interface RecordedSend {
}
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
readonly openCalls: LocalTransportTarget[];
readonly openCalls: OpenLocalTransportSessionInput[];
readonly recordedSends: RecordedSend[];
readonly closedSessions: string[];
resolveOpen(sessionId: string): void;
resolveRegistration(): void;
rejectOpen(error: Error): void;
resolveListen(cleanup: () => void): void;
rejectListen(error: Error): void;
@@ -22,12 +22,12 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
}
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
const openCalls: LocalTransportTarget[] = [];
const openCalls: OpenLocalTransportSessionInput[] = [];
const recordedSends: RecordedSend[] = [];
const closedSessions: string[] = [];
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
let resolveOpenSession: ((sessionId: string) => void) | null = null;
let rejectOpenSession: ((error: Error) => void) | null = null;
let resolveRegistrationPromise: (() => void) | null = null;
let rejectRegistrationPromise: ((error: Error) => void) | null = null;
let resolveListenPromise: ((cleanup: () => void) => void) | null = null;
let rejectListenPromise: ((error: Error) => void) | null = null;
@@ -35,11 +35,11 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
openCalls,
recordedSends,
closedSessions,
openSession(target) {
openCalls.push(target);
return new Promise<string>((resolve, reject) => {
resolveOpenSession = resolve;
rejectOpenSession = reject;
openSession(input) {
openCalls.push(input);
return new Promise<void>((resolve, reject) => {
resolveRegistrationPromise = resolve;
rejectRegistrationPromise = reject;
});
},
listenToEvents(handler) {
@@ -55,17 +55,17 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
async closeSession(sessionId) {
closedSessions.push(sessionId);
},
resolveOpen(sessionId) {
if (!resolveOpenSession) {
resolveRegistration() {
if (!resolveRegistrationPromise) {
throw new Error("openSession was not called");
}
resolveOpenSession(sessionId);
resolveRegistrationPromise();
},
rejectOpen(error) {
if (!rejectOpenSession) {
if (!rejectRegistrationPromise) {
throw new Error("openSession was not called");
}
rejectOpenSession(error);
rejectRegistrationPromise(error);
},
resolveListen(cleanup) {
if (!resolveListenPromise) {
@@ -0,0 +1,58 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import * as Clipboard from "expo-clipboard";
import { useToast } from "@/contexts/toast-context";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
// Everything the copy actions actually need. Kept narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface CopyableWorkspace {
workspaceId: string;
workspaceDirectory: string | null | undefined;
currentBranch: string | null | undefined;
}
export interface WorkspaceClipboardActions {
copyPath: (workspace: CopyableWorkspace) => void;
copyBranchName: (workspace: CopyableWorkspace) => void;
}
export function useWorkspaceClipboardActions(): WorkspaceClipboardActions {
const { t } = useTranslation();
const toast = useToast();
const copyPath = useCallback(
(workspace: CopyableWorkspace) => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
},
[t, toast],
);
const copyBranchName = useCallback(
(workspace: CopyableWorkspace) => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
},
[t, toast],
);
return useMemo(() => ({ copyPath, copyBranchName }), [copyBranchName, copyPath]);
}
+22
View File
@@ -1558,6 +1558,10 @@ export const ar: TranslationResources = {
title: "اتصال مباشر",
description: "الشبكة المحلية أو VPN.",
},
remoteSsh: {
title: "SSH عن بُعد",
description: "الاتصال عبر عميل SSH لسطح المكتب.",
},
scanQr: {
title: "مسح رمز QR",
description: "اتصال التتابع المشفر.",
@@ -1610,6 +1614,23 @@ export const ar: TranslationResources = {
details: "التفاصيل:{{detail}}",
},
},
remoteSsh: {
title: "SSH عن بُعد",
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
},
actions: {
cancel: "إلغاء",
connect: "اتصال",
connecting: "جارٍ الاتصال...",
},
errors: {
targetRequired: "مضيف SSH مطلوب",
invalidTarget: "أدخل مضيف ssh:// صالحًا",
failedToConnect: "تعذر الاتصال عبر SSH. {{detail}}",
},
},
link: {
title: "الصق رابط الاقتران",
helper: "الصق رابط الاقتران من الخادم الخاص بك.",
@@ -2244,6 +2265,7 @@ export const ar: TranslationResources = {
badges: {
relay: "تتابع",
local: "محلي",
remoteSsh: "SSH عن بُعد",
},
connections: {
title: "اتصالات",
+22
View File
@@ -1568,6 +1568,10 @@ export const en = {
title: "Direct connection",
description: "Local network or VPN.",
},
remoteSsh: {
title: "Remote SSH",
description: "Connect through the desktop SSH client.",
},
scanQr: {
title: "Scan QR code",
description: "Encrypted relay connection.",
@@ -1619,6 +1623,23 @@ export const en = {
details: "Details: {{detail}}",
},
},
remoteSsh: {
title: "Remote SSH",
helper: "Connect to a Paseo daemon running on the remote host.",
fields: {
target: "SSH host",
},
actions: {
cancel: "Cancel",
connect: "Connect",
connecting: "Connecting...",
},
errors: {
targetRequired: "SSH host is required",
invalidTarget: "Enter a valid ssh:// host",
failedToConnect: "Unable to connect over SSH. {{detail}}",
},
},
link: {
title: "Paste pairing link",
helper: "Paste the pairing link from your server.",
@@ -2342,6 +2363,7 @@ export const en = {
badges: {
relay: "Relay",
local: "Local",
remoteSsh: "Remote SSH",
},
connections: {
title: "Connections",
+22
View File
@@ -1602,6 +1602,10 @@ export const es: TranslationResources = {
title: "Conexión directa",
description: "Red local o VPN.",
},
remoteSsh: {
title: "SSH remoto",
description: "Conéctate mediante el cliente SSH de escritorio.",
},
scanQr: {
title: "Escanea el códigoQR",
description: "Conexión de retransmisión cifrada.",
@@ -1654,6 +1658,23 @@ export const es: TranslationResources = {
details: "Detalles:{{detail}}",
},
},
remoteSsh: {
title: "SSH remoto",
helper: "Conéctate a un daemon de Paseo en el host remoto.",
fields: {
target: "Host SSH",
},
actions: {
cancel: "Cancelar",
connect: "Conectar",
connecting: "Conectando...",
},
errors: {
targetRequired: "El host SSH es obligatorio",
invalidTarget: "Introduce un host ssh:// válido",
failedToConnect: "No se pudo conectar por SSH. {{detail}}",
},
},
link: {
title: "Pegar enlace de emparejamiento",
helper: "Pegue el enlace de emparejamiento de su servidor.",
@@ -2295,6 +2316,7 @@ export const es: TranslationResources = {
badges: {
relay: "Relé",
local: "Local",
remoteSsh: "SSH remoto",
},
connections: {
title: "Conexiones",
+22
View File
@@ -1606,6 +1606,10 @@ export const fr: TranslationResources = {
title: "Connexion directe",
description: "Réseau local ou VPN.",
},
remoteSsh: {
title: "SSH distant",
description: "Connectez-vous avec le client SSH de bureau.",
},
scanQr: {
title: "Scanner le codeQR",
description: "Connexion relais cryptée.",
@@ -1658,6 +1662,23 @@ export const fr: TranslationResources = {
details: "Détails:{{detail}}",
},
},
remoteSsh: {
title: "SSH distant",
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
fields: {
target: "Hôte SSH",
},
actions: {
cancel: "Annuler",
connect: "Connecter",
connecting: "Connexion...",
},
errors: {
targetRequired: "L’hôte SSH est requis",
invalidTarget: "Saisissez un hôte ssh:// valide",
failedToConnect: "Connexion SSH impossible. {{detail}}",
},
},
link: {
title: "Coller le lien d'association",
helper: "Collez le lien d'appairage depuis votre serveur.",
@@ -2300,6 +2321,7 @@ export const fr: TranslationResources = {
badges: {
relay: "Relais",
local: "Locale",
remoteSsh: "SSH distant",
},
connections: {
title: "Relations",
+22
View File
@@ -1573,6 +1573,10 @@ export const ja: TranslationResources = {
title: "直接接続",
description: "ローカルネットワークまたはVPN。",
},
remoteSsh: {
title: "リモート SSH",
description: "デスクトップの SSH クライアント経由で接続します。",
},
scanQr: {
title: "QRコードをスキャン",
description: "暗号化されたリレー接続。",
@@ -1624,6 +1628,23 @@ export const ja: TranslationResources = {
details: "詳細: {{detail}}",
},
},
remoteSsh: {
title: "リモート SSH",
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
},
actions: {
cancel: "キャンセル",
connect: "接続",
connecting: "接続中...",
},
errors: {
targetRequired: "SSH ホストは必須です",
invalidTarget: "有効な ssh:// ホストを入力してください",
failedToConnect: "SSH で接続できません。{{detail}}",
},
},
link: {
title: "ペアリングリンクを貼り付け",
helper: "サーバーからのペアリングリンクを貼り付けてください。",
@@ -2263,6 +2284,7 @@ export const ja: TranslationResources = {
badges: {
relay: "リレー",
local: "ローカル",
remoteSsh: "リモート SSH",
},
connections: {
title: "接続",
+22
View File
@@ -1568,6 +1568,10 @@ export const ko: TranslationResources = {
title: "직접 연결",
description: "로컬 네트워크 또는 VPN.",
},
remoteSsh: {
title: "원격 SSH",
description: "데스크톱 SSH 클라이언트를 통해 연결합니다.",
},
scanQr: {
title: "QR 코드 스캔",
description: "암호화된 릴레이 연결.",
@@ -1618,6 +1622,23 @@ export const ko: TranslationResources = {
details: "세부 정보: {{detail}}",
},
},
remoteSsh: {
title: "원격 SSH",
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
},
actions: {
cancel: "취소",
connect: "연결",
connecting: "연결 중...",
},
errors: {
targetRequired: "SSH 호스트가 필요합니다",
invalidTarget: "유효한 ssh:// 호스트를 입력하세요",
failedToConnect: "SSH로 연결할 수 없습니다. {{detail}}",
},
},
link: {
title: "페어링 링크 붙여넣기",
helper: "서버에서 받은 페어링 링크를 붙여넣으세요.",
@@ -2254,6 +2275,7 @@ export const ko: TranslationResources = {
badges: {
relay: "릴레이",
local: "로컬",
remoteSsh: "원격 SSH",
},
connections: {
title: "연결",
+22
View File
@@ -1588,6 +1588,10 @@ export const ptBR: TranslationResources = {
title: "Conexão direta",
description: "Rede local ou VPN.",
},
remoteSsh: {
title: "SSH remoto",
description: "Conecte-se pelo cliente SSH do desktop.",
},
scanQr: {
title: "Escanear QR code",
description: "Conexão relay criptografada.",
@@ -1640,6 +1644,23 @@ export const ptBR: TranslationResources = {
details: "Detalhes: {{detail}}",
},
},
remoteSsh: {
title: "SSH remoto",
helper: "Conecte-se a um daemon Paseo no host remoto.",
fields: {
target: "Host SSH",
},
actions: {
cancel: "Cancelar",
connect: "Conectar",
connecting: "Conectando...",
},
errors: {
targetRequired: "O host SSH é obrigatório",
invalidTarget: "Insira um host ssh:// válido",
failedToConnect: "Não foi possível conectar por SSH. {{detail}}",
},
},
link: {
title: "Colar link de pareamento",
helper: "Cole o link de pareamento do seu servidor.",
@@ -2279,6 +2300,7 @@ export const ptBR: TranslationResources = {
badges: {
relay: "Relay",
local: "Local",
remoteSsh: "SSH remoto",
},
connections: {
title: "Conexões",
+22
View File
@@ -1585,6 +1585,10 @@ export const ru: TranslationResources = {
title: "Прямое подключение",
description: "Локальная сеть или VPN.",
},
remoteSsh: {
title: "Удалённый SSH",
description: "Подключение через SSH-клиент настольного приложения.",
},
scanQr: {
title: "Сканировать QR-код",
description: "Зашифрованное подключение через ретранслятор.",
@@ -1637,6 +1641,23 @@ export const ru: TranslationResources = {
details: "Подробности: {{detail}}",
},
},
remoteSsh: {
title: "Удалённый SSH",
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
},
actions: {
cancel: "Отмена",
connect: "Подключить",
connecting: "Подключение...",
},
errors: {
targetRequired: "Укажите хост SSH",
invalidTarget: "Укажите корректный хост ssh://",
failedToConnect: "Не удалось подключиться по SSH. {{detail}}",
},
},
link: {
title: "Вставить ссылку для сопряжения",
helper: "Вставьте ссылку на сопряжение с вашего сервера.",
@@ -2281,6 +2302,7 @@ export const ru: TranslationResources = {
badges: {
relay: "Ретранслятор",
local: "Локальный",
remoteSsh: "Удалённый SSH",
},
connections: {
title: "Подключения",
+22
View File
@@ -1541,6 +1541,10 @@ export const zhCN: TranslationResources = {
title: "直接连接",
description: "本地网络或 VPN。",
},
remoteSsh: {
title: "远程 SSH",
description: "通过桌面 SSH 客户端连接。",
},
scanQr: {
title: "扫描二维码",
description: "加密 relay 连接。",
@@ -1591,6 +1595,23 @@ export const zhCN: TranslationResources = {
details: "详情:{{detail}}",
},
},
remoteSsh: {
title: "远程 SSH",
helper: "连接到远程主机上运行的 Paseo 守护进程。",
fields: {
target: "SSH 主机",
},
actions: {
cancel: "取消",
connect: "连接",
connecting: "正在连接...",
},
errors: {
targetRequired: "SSH 主机为必填项",
invalidTarget: "请输入有效的 ssh:// 主机",
failedToConnect: "无法通过 SSH 连接。{{detail}}",
},
},
link: {
title: "粘贴配对链接",
helper: "粘贴来自 server 的配对链接。",
@@ -2218,6 +2239,7 @@ export const zhCN: TranslationResources = {
badges: {
relay: "Relay",
local: "本地",
remoteSsh: "远程 SSH",
},
connections: {
title: "连接",
@@ -51,7 +51,10 @@ export type KeyboardActionId =
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin";
| "workspace.pin"
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
| "workspace.rename"
| "workspace.setup.show";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -106,7 +109,9 @@ export type KeyboardActionDefinition =
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope };
| { id: "workspace.pin"; scope: KeyboardActionScope }
| { id: "workspace.rename"; scope: KeyboardActionScope }
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
+5
View File
@@ -67,6 +67,7 @@ import {
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
import {
type HostRuntimeConnectionStatus,
@@ -1288,11 +1289,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
parentAgentId: agentId,
rows: subagentRows,
});
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const hasVisibleAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
hasPluginComposerPills,
});
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
@@ -1379,11 +1382,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
cwd={cwd}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
hasPluginComposerPills={hasPluginComposerPills}
/>
) : null}
</ReanimatedAnimated.View>
+28 -2
View File
@@ -6,6 +6,7 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import {
type ArchiveFinishedStatus,
@@ -30,19 +31,23 @@ import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
agentId,
cwd,
subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
hasPluginComposerPills,
}: {
serverId: string;
workspaceId: string;
agentId: string;
cwd: string;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
hasPluginComposerPills: boolean;
}): ReactElement | null {
const { tabId, openTab } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
@@ -107,7 +112,15 @@ export const AgentTracks = memo(function AgentTracks({
});
}, [cwd, isCompact, openInSidePane, serverId, workspaceKey]);
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
if (
!hasWorkspaceDiffStat &&
!hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills,
})
) {
return null;
}
@@ -123,6 +136,12 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<PluginComposerPills
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
compact={isCompact}
/>
<WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
@@ -136,10 +155,17 @@ export function hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills = false,
}: {
subagentRows: readonly SubagentRow[];
tasks: readonly TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
hasPluginComposerPills?: boolean;
}): boolean {
return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
return (
subagentRows.length > 0 ||
Boolean(tasks?.length) ||
archiveFinishedStatus.kind !== "idle" ||
hasPluginComposerPills
);
}
+94
View File
@@ -0,0 +1,94 @@
import { callPluginRpc } from "@getpaseo/plugin/host";
import type {
PluginAgentCommandContext,
PluginCommandCapabilities,
PluginPanelLocation,
PluginWorkspaceCommandContext,
} from "@getpaseo/plugin";
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
import type { PluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
export interface PluginNavigation {
openSurface(pluginId: string, surfaceId: string): void;
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
openAgentPanel(
pluginId: string,
panelId: string,
agentId: string,
location: PluginPanelLocation,
): void;
}
export function createPluginCapabilities(
plugin: InstalledPlugin,
runtime: PluginSurfaceRuntime,
navigation: PluginNavigation,
): PluginCommandCapabilities {
return {
paseo: runtime.paseo,
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
openSurface(surfaceId) {
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
}
navigation.openSurface(plugin.id, surfaceId);
},
};
}
export function createPluginAgentActionContext(input: {
plugin: InstalledPlugin;
runtime: PluginSurfaceRuntime;
navigation: PluginNavigation;
state: PluginClientStateSource;
workspaceId: string;
agentId: string;
}): PluginAgentCommandContext | null {
const { plugin, runtime, navigation, state, workspaceId, agentId } = input;
const workspace = state.getWorkspace(workspaceId);
const agent = state.getAgent(agentId);
if (!workspace || !agent || agent.workspaceId !== workspace.id) return null;
return {
context: "agent",
...createPluginCapabilities(plugin, runtime, navigation),
workspace,
agent,
openPanel(panelId, options) {
const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
const location = resolvePluginPanelOpenLocation(panel, options?.location);
if (panel.context === "workspace") {
navigation.openWorkspacePanel(plugin.id, panelId, location);
return;
}
navigation.openAgentPanel(plugin.id, panelId, agent.id, location);
},
};
}
export function createPluginWorkspaceActionContext(input: {
plugin: InstalledPlugin;
runtime: PluginSurfaceRuntime;
navigation: PluginNavigation;
state: PluginClientStateSource;
workspaceId: string;
}): PluginWorkspaceCommandContext | null {
const { plugin, runtime, navigation, state, workspaceId } = input;
const workspace = state.getWorkspace(workspaceId);
if (!workspace) return null;
return {
context: "workspace",
...createPluginCapabilities(plugin, runtime, navigation),
workspace,
openPanel(panelId, options) {
const panel = plugin.workspacePanels.find(
(candidate) => candidate.id === panelId && candidate.context === "workspace",
);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
const location = resolvePluginPanelOpenLocation(panel, options?.location);
navigation.openWorkspacePanel(plugin.id, panelId, location);
},
};
}
@@ -1,91 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => connection.connected,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => connection.supported,
}));
import { PluginCatalogSync } from "./catalog-sync";
import { pluginRegistry } from "./registry";
function bundle(): string {
return `(function() { return { default: function(plugin) {
plugin.addSurface("main", function() { return null; });
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
} }; })`;
}
const unsubscribe = vi.fn();
const catalogClient = {
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
on: vi.fn(() => unsubscribe),
} as unknown as DaemonClient;
describe("PluginCatalogSync", () => {
let container: HTMLElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
connection.connected = true;
connection.supported = true;
unsubscribe.mockReset();
vi.mocked(catalogClient.getPluginCatalog).mockClear();
vi.mocked(catalogClient.on).mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
pluginRegistry.removeHost("host-a");
container.remove();
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
vi.unstubAllGlobals();
});
async function renderSync(): Promise<void> {
await act(async () => {
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
await Promise.resolve();
});
}
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
await renderSync();
const first = pluginRegistry.getSnapshot()[0];
expect(first?.id).toBe("example");
first?.queryClient.setQueryData(["owned"], "state");
connection.connected = false;
await renderSync();
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
expect(pluginRegistry.getSnapshot()).toEqual([]);
connection.connected = true;
await renderSync();
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
act(() => root.unmount());
root = createRoot(container);
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
expect(pluginRegistry.getSnapshot()).toEqual([]);
expect(unsubscribe).toHaveBeenCalledTimes(2);
});
});
@@ -34,6 +34,7 @@ export function PluginCatalogSync({
if (!cancelled) {
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
replacePluginId,
client,
});
if (timelineChanged) {
useSessionStore
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
onSelect: onAgentSelect,
},
],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -1,5 +1,3 @@
import { callPluginRpc } from "@getpaseo/plugin/host";
import type { PluginCommandCapabilities, PluginPanelLocation } from "@getpaseo/plugin";
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
@@ -7,17 +5,7 @@ import { resolvePluginIcon } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { PluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
export interface PluginCommandCenterNavigation {
openSurface(pluginId: string, surfaceId: string): void;
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
openAgentPanel(
pluginId: string,
panelId: string,
agentId: string,
location: PluginPanelLocation,
): void;
}
import { createPluginCapabilities, type PluginNavigation } from "../actions";
export interface PluginCommandCenterSource {
plugins: readonly InstalledPlugin[];
@@ -25,34 +13,17 @@ export interface PluginCommandCenterSource {
state: PluginClientStateSource;
workspaceId: string | null;
agentId: string | null;
navigation: PluginCommandCenterNavigation;
navigation: PluginNavigation;
reportError(error: unknown): void;
}
function capabilities(
plugin: InstalledPlugin,
runtime: PluginSurfaceRuntime,
navigation: PluginCommandCenterNavigation,
): PluginCommandCapabilities {
return {
paseo: runtime.paseo,
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
openSurface(surfaceId) {
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
}
navigation.openSurface(plugin.id, surfaceId);
},
};
}
export function buildPluginCommandCenterContributions(
source: PluginCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
for (const plugin of source.plugins) {
const runtime = source.runtime(plugin.id);
const common = capabilities(plugin, runtime, source.navigation);
const common = createPluginCapabilities(plugin, runtime, source.navigation);
for (const [rank, item] of plugin.commandCenterItems.entries()) {
if (item.context === "workspace" && !source.workspaceId) continue;
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
@@ -1,23 +1,20 @@
import { router, usePathname } from "expo-router";
import { usePathname } from "expo-router";
import { useMemo } from "react";
import { useCommandCenterActions } from "@/command-center/provider";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import {
useActiveWorkspaceSelection,
navigateToWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { hostIdFromPathname } from "../routes";
import { useInstalledPlugins } from "../registry";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { buildPluginCommandCenterContributions } from "./contributions";
import { getFocusedAgentId } from "./context";
import { createPluginNavigation } from "../navigation";
export function PluginCommandCenterActions() {
const pathname = usePathname();
@@ -64,33 +61,7 @@ export function PluginCommandCenterActions() {
state: stateSource,
workspaceId: workspaceExists ? workspaceId : null,
agentId: agentExists ? focusedAgentId : null,
navigation: {
openSurface(pluginId, surfaceId) {
router.push(
buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }),
);
},
openWorkspacePanel(pluginId, panelId, location) {
if (!workspaceId) throw new Error("No active workspace");
const placement = resolvePluginPanelPlacement(workspaceKey, location);
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
placement,
});
},
openAgentPanel(pluginId, panelId, agentId, location) {
if (!workspaceId) throw new Error("No active workspace");
const placement = resolvePluginPanelPlacement(workspaceKey, location);
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
placement,
});
},
},
navigation: createPluginNavigation({ serverId, workspaceId }),
reportError(error) {
toast.error(error instanceof Error ? error.message : String(error));
},
@@ -105,7 +76,6 @@ export function PluginCommandCenterActions() {
toast,
workspaceExists,
workspaceId,
workspaceKey,
]);
useCommandCenterActions({
@@ -115,11 +85,3 @@ export function PluginCommandCenterActions() {
});
return null;
}
function resolvePluginPanelPlacement(workspaceKey: string | null, location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
if (!workspaceKey) throw new Error("No active workspace");
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
if (!paneId) throw new Error("Explorer is unavailable");
return { mode: "pane" as const, paneId };
}
@@ -0,0 +1,180 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { composerPillStyles } from "@/composer/pill-styles";
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 { PluginRuntimeBoundary } from "../runtime-boundary";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
import { pluginComposerPillStore } from "./store";
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginComposerPill({
plugin,
contribution,
serverId,
workspaceId,
agentId,
compact,
hostLabel,
theme,
}: {
plugin: InstalledPlugin;
contribution: PluginComposerPillContribution;
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}) {
const client = useHostRuntimeClient(serverId);
const toast = useToast();
const [pending, setPending] = useState(false);
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const props = useMemo<PluginComposerPillProps>(
() => ({
theme,
host: { id: serverId, label: hostLabel },
layout: { compact, platform: resolvePlatform() },
workspaceId,
agentId,
}),
[agentId, compact, hostLabel, serverId, theme, workspaceId],
);
const press = useCallback(async () => {
if (!runtime || pending) return;
setPending(true);
try {
await contribution.onPress();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setPending(false);
}
}, [contribution, pending, runtime, toast]);
const pillStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
composerPillStyles.body,
styles.pill,
(hovered || pressed) && composerPillStyles.bodyActive,
pending && styles.disabled,
],
[pending],
);
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
if (!runtime) return null;
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginClientStateProvider source={state}>
<Pressable
accessibilityRole="button"
accessibilityLabel={contribution.title}
accessibilityState={accessibilityState}
disabled={pending}
onPress={press}
style={pillStyle}
>
<Component {...props} />
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
</Pressable>
</PluginClientStateProvider>
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
);
}
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
export function PluginComposerPills({
serverId,
workspaceId,
agentId,
compact,
}: {
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
}) {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
const hosts = useHosts();
const visible = useMemo(
() =>
registrations.filter(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
),
[agentId, registrations, serverId, workspaceId],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
return (
<>
{visible.map(({ installation, contribution }) => (
<ThemedPluginComposerPill
key={`${installation.id}/${contribution.id}`}
plugin={installation}
contribution={contribution}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
))}
</>
);
}
export function useHasPluginComposerPills(
serverId: string,
workspaceId: string,
agentId: string,
): boolean {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
return registrations.some(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
);
}
const styles = StyleSheet.create(() => ({
pill: {
flexShrink: 1,
minWidth: 0,
},
disabled: {
opacity: 0.5,
},
}));
@@ -0,0 +1,111 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { startPluginClientSide } from "./lifecycle";
import { pluginComposerPillStore } from "./store";
function Pill(_props: PluginComposerPillProps) {
return null;
}
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide,
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
}
const daemonClient = {} as DaemonClient;
const installations: InstalledPlugin[] = [];
afterEach(() => {
for (const plugin of installations.splice(0)) {
pluginComposerPillStore.removeInstallation(plugin);
}
});
describe("plugin client-side lifecycle", () => {
it("lets client code add and remove a targeted composer pill at runtime", async () => {
const captured: { client?: PluginClientContext } = {};
let cleanupCount = 0;
const plugin = installation((context) => {
captured.client = context;
return () => {
cleanupCount += 1;
};
});
installations.push(plugin);
const stop = startPluginClientSide(plugin, daemonClient);
const client = captured.client;
if (!client) throw new Error("Client lifecycle did not start");
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
const remove = client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
expect(
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
id: contribution.id,
workspaceId: contribution.workspaceId,
agentId: contribution.agentId,
})),
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
remove();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
await stop();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
expect(cleanupCount).toBe(1);
});
it("rejects duplicate pills only within the same plugin target", () => {
const plugin = installation(() => () => undefined);
installations.push(plugin);
const contribution = {
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
};
pluginComposerPillStore.add(plugin, contribution);
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
"Duplicate composer pill: review-ready",
);
expect(() =>
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
).not.toThrow();
});
});
@@ -0,0 +1,90 @@
import type {
PluginCleanup,
PluginClientContext,
PluginClientOpenPanelOptions,
} from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
createPluginAgentActionContext,
createPluginCapabilities,
createPluginWorkspaceActionContext,
} from "../actions";
import { createPluginClientStateSource } from "../client-state/source";
import { createPluginNavigation } from "../navigation";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
import { pluginComposerPillStore } from "./store";
export function startPluginClientSide(
installation: InstalledPlugin,
daemonClient: DaemonClient,
): PluginCleanup {
if (!installation.clientSide) return () => undefined;
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
if (!runtime) throw new Error("Plugin host is offline");
const state = createPluginClientStateSource(installation.serverId);
const capabilities = createPluginCapabilities(
installation,
runtime,
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
);
const context: PluginClientContext = {
...capabilities,
addComposerPill(contribution) {
return pluginComposerPillStore.add(installation, contribution);
},
openPanel(panelId, options) {
openClientPanel({ installation, runtime, state, panelId, options });
},
};
let cleanup: PluginCleanup;
try {
cleanup = installation.clientSide(context);
if (typeof cleanup !== "function") {
throw new Error("Plugin client-side entrypoint must return a cleanup function");
}
} catch (error) {
pluginComposerPillStore.removeInstallation(installation);
throw error;
}
let stopped = false;
return async () => {
if (stopped) return;
stopped = true;
pluginComposerPillStore.removeInstallation(installation);
await cleanup();
};
}
function openClientPanel(input: {
installation: InstalledPlugin;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
state: ReturnType<typeof createPluginClientStateSource>;
panelId: string;
options: PluginClientOpenPanelOptions;
}): void {
const { installation, runtime, state, panelId, options } = input;
const workspaceId = options.workspaceId.trim();
const agentId = options.agentId?.trim();
const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId });
const action = agentId
? createPluginAgentActionContext({
plugin: installation,
runtime,
navigation,
state,
workspaceId,
agentId,
})
: createPluginWorkspaceActionContext({
plugin: installation,
runtime,
navigation,
state,
workspaceId,
});
if (!action) throw new Error("Plugin panel context is unavailable");
action.openPanel(panelId, { location: options.location });
}
@@ -0,0 +1,78 @@
import type { PluginComposerPillContribution } from "@getpaseo/plugin";
import type { InstalledPlugin } from "../types";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
export interface RegisteredPluginComposerPill {
installation: InstalledPlugin;
contribution: PluginComposerPillContribution;
}
class PluginComposerPillStore {
private entries: RegisteredPluginComposerPill[] = [];
private readonly listeners = new Set<() => void>();
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
const contribution = validateContribution(input);
const duplicate = this.entries.some(
(entry) =>
entry.installation === installation &&
entry.contribution.workspaceId === contribution.workspaceId &&
entry.contribution.agentId === contribution.agentId &&
entry.contribution.id === contribution.id,
);
if (duplicate) {
throw new Error(`Duplicate composer pill: ${contribution.id}`);
}
const entry = { installation, contribution };
this.entries = [...this.entries, entry];
this.publish();
let removed = false;
return () => {
if (removed) return;
removed = true;
this.entries = this.entries.filter((candidate) => candidate !== entry);
this.publish();
};
}
removeInstallation(installation: InstalledPlugin): void {
const next = this.entries.filter((entry) => entry.installation !== installation);
if (next.length === this.entries.length) return;
this.entries = next;
this.publish();
}
private publish(): void {
for (const listener of this.listeners) listener();
}
}
function validateContribution(
contribution: PluginComposerPillContribution,
): PluginComposerPillContribution {
const id = contribution.id.trim();
const title = contribution.title.trim();
const workspaceId = contribution.workspaceId.trim();
const agentId = contribution.agentId.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
if (!title) throw new Error(`Composer pill ${id} has no title`);
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
if (typeof contribution.Component !== "function") {
throw new Error(`Composer pill ${id} is not a component`);
}
if (typeof contribution.onPress !== "function") {
throw new Error(`Composer pill ${id} has no callback`);
}
return { ...contribution, id, title, workspaceId, agentId };
}
export const pluginComposerPillStore = new PluginComposerPillStore();
+42
View File
@@ -212,6 +212,28 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate Command Center item: review");
});
it("collects one explicit client-side entrypoint", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
`),
);
expect(plugin.clientSide).toBeTypeOf("function");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
plugin.addClientSide(contributeClient);
`),
),
).toThrow("Plugin has more than one client-side entrypoint");
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
@@ -355,6 +377,26 @@ describe("evaluatePluginClientBundle", () => {
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
});
it("provides Paseo UI through @getpaseo/plugin/react-native", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native");
const module = { exports: {} };
module.exports.default = function(plugin) {
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
throw new Error("React Native plugin UI is incomplete");
}
plugin.addSurface("main", function Surface() { return null; });
return function() {};
};
return module.exports;
})`,
);
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
+14
View File
@@ -10,6 +10,7 @@ import {
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginClientContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginThemeContribution,
@@ -25,6 +26,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { Icon, resolvePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -71,6 +73,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -163,6 +166,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
});
},
addClientSide(contribution: PluginClientContribution) {
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
if (typeof contribution !== "function") {
throw new Error("Plugin client-side entrypoint is not a function");
}
collector.clientSide = contribution;
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
@@ -254,6 +264,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
useRpc,
};
}
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@getpaseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
}
@@ -299,6 +312,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
sidebarItems: collector.sidebarItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSide: collector.clientSide,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
+1
View File
@@ -4,3 +4,4 @@ export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
+44
View File
@@ -0,0 +1,44 @@
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildPluginSurfaceRoute } from "./routes";
import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: {
serverId: string;
workspaceId: string | null;
}): PluginNavigation {
const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
if (!workspaceId) throw new Error("No active workspace");
const workspaceKey = `${serverId}:${workspaceId}`;
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
if (!paneId) throw new Error("Explorer is unavailable");
return { mode: "pane" as const, paneId };
}
return {
openSurface(pluginId, surfaceId) {
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
},
openWorkspacePanel(pluginId, panelId, location) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
placement: placement(location),
});
},
openAgentPanel(pluginId, panelId, agentId, location) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
placement: placement(location),
});
},
};
}
@@ -0,0 +1,61 @@
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native";
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host";
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
interface ModalContextValue {
open: boolean;
dismiss(): void;
header: {
title: string;
leading?: ReactNode;
};
}
const ModalContext = createContext<ModalContextValue | null>(null);
function ModalRoot({ title, icon, open, onOpenChange, children }: ModalProps) {
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const dismiss = useCallback(() => onOpenChangeRef.current(false), []);
const header = useMemo(() => ({ title, leading: icon }), [icon, title]);
const value = useMemo(() => ({ open, dismiss, header }), [dismiss, header, open]);
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
}
function useModalContext(): ModalContextValue {
const context = useContext(ModalContext);
if (!context) throw new Error("Modal.Content must be rendered inside Modal");
return context;
}
function ModalContent({ children }: ModalContentProps) {
const modal = useModalContext();
const queryClient = useQueryClient();
const toast = useAppToast();
const bridgePluginRuntime = usePluginRuntimeContextBridge();
const contextBridge = useCallback(
(content: ReactNode) => (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>{bridgePluginRuntime(content)}</ToastApiProvider>
</QueryClientProvider>
),
[bridgePluginRuntime, queryClient, toast],
);
const { AdaptiveModalSheet } =
require("../../components/adaptive-modal-sheet") as typeof import("../../components/adaptive-modal-sheet");
return (
<AdaptiveModalSheet
visible={modal.open}
onClose={modal.dismiss}
header={modal.header}
contextBridge={contextBridge}
>
{children}
</AdaptiveModalSheet>
);
}
export const Modal: ModalComponent = Object.assign(ModalRoot, { Content: ModalContent });
@@ -0,0 +1,5 @@
import { Icon } from "../icons";
import { Modal } from "./modal";
import { useToast } from "./toast";
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
@@ -0,0 +1,18 @@
import { useMemo } from "react";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native";
import { useToast as useAppToast } from "@/contexts/toast-api-context";
export function useToast(): ToastApi {
const toast = useAppToast();
return useMemo(
() => ({
show(message: string, options?: ToastOptions) {
toast.show(message, options);
},
error(message: string) {
toast.error(message);
},
}),
[toast],
);
}
+29 -10
View File
@@ -1,5 +1,7 @@
import { useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { startPluginClientSide } from "./composer-pills/lifecycle";
import { evaluatePluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
@@ -29,7 +31,10 @@ class PluginRegistry {
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
options: { replacePluginId?: string } = {},
options: {
replacePluginId?: string;
client?: DaemonClient;
} = {},
): boolean {
const previous = this.byHost.get(serverId) ?? [];
const previousTimelineBundles = previous
@@ -61,16 +66,30 @@ class PluginRegistry {
return [existing];
}
const queryClient = new QueryClient();
const evaluated = [
{
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
serverId,
clientBundle: entry.clientBundle,
queryClient,
},
];
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
const installation: InstalledPlugin = {
...evaluated,
serverId,
clientBundle: entry.clientBundle,
queryClient,
};
if (installation.clientSide) {
if (!options.client) throw new Error("Plugin client runtime is unavailable");
let clientCleanup;
try {
clientCleanup = startPluginClientSide(installation, options.client);
} catch (error) {
queryClient.clear();
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
throw error;
}
installation.cleanup = async () => {
await clientCleanup();
await evaluated.cleanup();
};
}
this.evaluationErrors.delete(key);
return evaluated;
return [installation];
} catch (error) {
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
@@ -21,6 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -26,6 +26,7 @@ function installation(
})),
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
+1
View File
@@ -54,6 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes,
timelineTransformers: [],
@@ -17,6 +17,7 @@ function plugin(input: {
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [
+6 -1
View File
@@ -2,6 +2,9 @@ import type { QueryClient } from "@tanstack/react-query";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientContribution,
PluginCleanup,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
@@ -17,11 +20,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
export interface EvaluatedPlugin {
id: string;
cleanup: () => void;
cleanup: PluginCleanup;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSide: PluginClientContribution | null;
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
timelineTransformers: PluginTimelineTransformerContribution[];
@@ -37,6 +41,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
export type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
},
],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -6,6 +6,10 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
@@ -208,3 +212,21 @@ it("does not invent a null-key project from a workspace update", () => {
expect(session?.projects.has("fresh-project")).toBe(false);
store.clearSession(serverId);
});
it("does not restore a targeted cached workspace while its archive is pending", () => {
const serverId = "cached-workspace-during-archive";
const workspaceId = "archived-workspace";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
markWorkspaceArchivePending({ serverId, workspaceId });
try {
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId });
store.clearSession(serverId);
}
});
@@ -96,6 +96,7 @@ export class WorkspaceDirectoryReplica {
workspace: WorkspaceDescriptor,
project: ProjectDescriptor | undefined,
): void {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
const snapshot = this.read();
snapshot.workspaces.set(workspace.id, workspace);
if (project) snapshot.projects.set(project.projectId, project);
+48 -5
View File
@@ -9,6 +9,7 @@ import {
} from "@getpaseo/client/internal/daemon-client";
import {
connectionFromListen,
createRemoteSshHostConnection,
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
@@ -38,8 +39,8 @@ import {
type ConnectionProbeState,
} from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
buildDesktopDaemonTransportUrl,
createDesktopDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -81,6 +82,7 @@ export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
| { type: "remoteSsh"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" };
export type HostRuntimeAgentDirectoryStatus =
@@ -211,6 +213,13 @@ function toActiveConnection(connection: HostConnection): ActiveConnection {
display: "pipe",
};
}
if (connection.type === "remoteSsh") {
return {
type: "remoteSsh",
endpoint: connection.host,
display: connection.host,
};
}
if (connection.type === "directTcp") {
return {
type: "directTcp",
@@ -493,7 +502,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const desktopTransportFactory = createDesktopDaemonTransportFactory();
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
const base = {
suppressSendErrors: true,
@@ -507,13 +516,28 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
...base,
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
url: buildLocalDaemonTransportUrl({
...(desktopTransportFactory ? { transportFactory: desktopTransportFactory } : {}),
url: buildDesktopDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
});
}
if (connection.type === "remoteSsh") {
if (!desktopTransportFactory) {
throw new Error("Remote SSH is only available in the desktop app.");
}
return new DaemonClient({
...base,
transportFactory: desktopTransportFactory,
url: buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: connection.host,
...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}),
...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}),
}),
});
}
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
@@ -1754,6 +1778,18 @@ export class HostRuntimeStore {
});
}
async probeAndUpsertRemoteSshConnection(input: {
host: string;
sshPort?: number;
daemonPort?: number;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
return this.probeAndUpsertConnection({
label: input.label,
connection: createRemoteSshHostConnection(input),
});
}
async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
@@ -2488,6 +2524,12 @@ export interface HostMutations {
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
probeAndUpsertRemoteSshConnection: (input: {
host: string;
sshPort?: number;
daemonPort?: number;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
@@ -2513,6 +2555,7 @@ export function useHostMutations(): HostMutations {
() => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
@@ -12,6 +12,14 @@ import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore }
const SERVER_ID = "cached-host";
function deferred(): { promise: Promise<void>; resolve(): void } {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
class MemoryStorage implements ReplicaRowStore {
readonly rows = new Map<string, ReplicaRow>();
readonly changes: ReplicaRowChanges[] = [];
@@ -23,6 +31,8 @@ class MemoryStorage implements ReplicaRowStore {
writes = 0;
cleanups = 0;
nextWriteFailure: Error | null = null;
readGate: Promise<void> | null = null;
onRead: (() => void) | null = null;
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
return `${row.serverId}:${row.kind}:${row.id}`;
@@ -36,6 +46,8 @@ class MemoryStorage implements ReplicaRowStore {
ids?: readonly string[],
): Promise<ReplicaRow[]> {
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
this.onRead?.();
await this.readGate;
const acceptedKinds = new Set(kinds);
const acceptedIds = ids ? new Set(ids) : null;
return [...this.rows.values()].filter(
@@ -217,6 +229,72 @@ describe("ReplicaCache", () => {
expect(restoredTimeline).toEqual(timeline());
});
it("never reads directory rows older than an accepted deferred deletion", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
await cache.flush();
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0);
});
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
await cache.flush();
storage.nextWriteFailure = new Error("disk busy");
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
});
it("discards a durable read when the host changes while it is in flight", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
await cache.flush();
const started = deferred();
const release = deferred();
storage.onRead = started.resolve;
storage.readGate = release.promise;
const reading = cache.readAgent(SERVER_ID, "agent-1");
await started.promise;
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
release.resolve();
expect(await reading).toBeUndefined();
});
it("never reads a timeline older than an accepted deferred replacement", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
await cache.flush();
cache.commitTimeline(SERVER_ID, "agent-1", timeline("New"));
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem("New")]);
});
it("round-trips plugin timeline items", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -784,6 +784,7 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und
export class ReplicaCache {
private readonly activeServerIds = new Set<string>();
private readonly hostRevisions = new Map<string, number>();
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
private readonly hostBytes = new Map<string, number>();
private readonly hostWriteOrder = new Map<string, true>();
@@ -897,9 +898,13 @@ export class ReplicaCache {
ids?: readonly string[],
): Promise<ReplicaRow[]> {
if (!this.activeServerIds.has(serverId)) return [];
const revision = this.hostRevisions.get(serverId) ?? 0;
try {
await this.prepareStore();
return await this.rowStore.read(serverId, kinds, ids);
await this.flush();
if (!this.canReadHostRevision(serverId, revision)) return [];
const rows = await this.rowStore.read(serverId, kinds, ids);
return this.canReadHostRevision(serverId, revision) ? rows : [];
} catch {
return [];
}
@@ -985,6 +990,7 @@ export class ReplicaCache {
},
): void {
if (!this.activeServerIds.has(serverId)) return;
this.advanceHostRevision(serverId);
this.clearPendingDirectoryChanges(serverId);
const desiredRows: ReplicaRow[] = [];
for (const agent of directory.agents.values()) {
@@ -1013,6 +1019,7 @@ export class ReplicaCache {
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void {
if (!this.activeServerIds.has(serverId)) return;
if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload");
this.advanceHostRevision(serverId);
const stored = serializeTimeline(timeline);
if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored);
else this.queueEntityDelete(serverId, "timeline", agentId);
@@ -1022,6 +1029,8 @@ export class ReplicaCache {
setHosts(serverIds: Iterable<string>): void {
const next = new Set(serverIds);
const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId));
const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId));
for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId);
this.activeServerIds.clear();
for (const serverId of next) this.activeServerIds.add(serverId);
for (const serverId of removed) {
@@ -1032,6 +1041,8 @@ export class ReplicaCache {
}
reconcileServerId(oldServerId: string, newServerId: string): void {
this.advanceHostRevision(oldServerId);
this.advanceHostRevision(newServerId);
const rows = this.storedRows.get(oldServerId);
if (rows) {
const newRows = this.storedRows.get(newServerId) ?? new Map<string, ReplicaRow>();
@@ -1142,6 +1153,29 @@ export class ReplicaCache {
);
}
private canReadHostRevision(serverId: string, revision: number): boolean {
return (
this.activeServerIds.has(serverId) &&
(this.hostRevisions.get(serverId) ?? 0) === revision &&
!this.hasPendingHostChanges(serverId)
);
}
private hasPendingHostChanges(serverId: string): boolean {
if (this.pendingDirectoryReplacements.has(serverId)) return true;
for (const row of this.pendingUpserts.values()) {
if (row.serverId === serverId) return true;
}
for (const row of this.pendingDeletes.values()) {
if (row.serverId === serverId) return true;
}
return false;
}
private advanceHostRevision(serverId: string): void {
this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1);
}
private drainPendingChanges(): PendingReplicaChanges {
const changes = {
upserts: [...this.pendingUpserts.values()],
@@ -217,6 +217,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
workspaceKind: "directory",
status: "done",
currentBranch: "main",
changeRequestNumber: null,
...overrides,
};
}
@@ -74,6 +74,7 @@ import { BackHeader } from "@/components/headers/back-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
@@ -1187,6 +1188,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
@@ -1293,11 +1295,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
@@ -1319,6 +1323,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
@@ -1532,6 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
@@ -1541,6 +1551,12 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
@@ -111,6 +111,9 @@ function formatHostConnectionLabel(connection: HostConnection, t: TFunction): st
if (connection.type === "directSocket" || connection.type === "directPipe") {
return `${t("settings.host.badges.local")} (${connection.path})`;
}
if (connection.type === "remoteSsh") {
return `${t("settings.host.badges.remoteSsh")} (${connection.host})`;
}
return `TCP (${connection.endpoint})`;
}
@@ -132,6 +135,12 @@ function formatActiveConnectionBadge(
text: t("settings.host.badges.local"),
};
}
if (activeConnection.type === "remoteSsh") {
return {
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: t("settings.host.badges.remoteSsh"),
};
}
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
@@ -186,7 +195,6 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const statusDotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: statusDotColor }],
[statusDotColor],
@@ -3304,7 +3304,8 @@ function WorkspaceScreenContent({
],
);
const workspaceKeyboardActionsEnabled = Boolean(
// Shared by every handler below: these actions only exist on a focused workspace route.
const workspaceActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
@@ -3323,7 +3324,7 @@ function WorkspaceScreenContent({
"workspace.browser.new",
"workspace.tab.menu.open",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceTabAction,
@@ -3341,7 +3342,7 @@ function WorkspaceScreenContent({
"workspace.tab.target.changes",
"workspace.tab.target.files",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceDirectTargetAction,
@@ -3354,7 +3355,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["workspace.tab.open"] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePanelOpenAction,
@@ -3373,7 +3374,7 @@ function WorkspaceScreenContent({
"workspace.tab.copy-id",
"workspace.tab.copy-file-path",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabMetadataAction,
@@ -3390,7 +3391,7 @@ function WorkspaceScreenContent({
"workspace.tab.close-right",
"workspace.tab.close-others",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabCloseAction,
@@ -3416,7 +3417,7 @@ function WorkspaceScreenContent({
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
@@ -3429,12 +3430,28 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
});
// Gated on the same predicate as the header menu item, so the command center never lists a
// Show setup entry the menu would hide.
// Gated by isActive so the handler is only dispatched when the workspace has visible setup;
// the command center contribution is separately gated by canShowSetup in workspace-registration.
useKeyboardActionHandler({
handlerId: `workspace-setup-show:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: ["workspace.setup.show"] as const,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => showWorkspaceSetup,
handle: () => {
handleOpenSetupTab();
return true;
},
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
@@ -3391,6 +3391,99 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
});
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: "main",
tabIds: ["new", "draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
new: { kind: "new_tab" },
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: "explorer",
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: "main",
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: "explorer",
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
"main",
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: "explorer",
},
},
}));
const emptySnapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
);
expect(mainTabs).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
@@ -1182,9 +1182,15 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
const currentLayout = getWorkspaceLayout(
state.layoutByWorkspace,
normalizedWorkspaceKey,
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
rawLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
);
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
rawLayout,
explorerSidebarPaneId,
rawLayout.focusedPaneId,
);
const nextState = reconcileWorkspaceTabs(
{
@@ -1192,21 +1198,23 @@ export function createWorkspaceLayoutStore(
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
currentLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
),
explorerSidebarPaneId,
},
snapshot,
);
if (nextState.layout === currentLayout) {
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
nextState.layout,
explorerSidebarPaneId,
currentLayout.focusedPaneId,
);
if (nextLayout === rawLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextState.layout,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
+5 -1
View File
@@ -16,7 +16,11 @@ function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_S
*
* Usage:
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
* <Markdown style={markdownStyles}>{content}</Markdown>
* <Markdown style={markdownStyles} markdownit={parser}>{content}</Markdown>
*
* Always pass `markdownit` from `@/utils/markdown-parser`. Omit it and
* react-native-markdown-display builds its own parser with `typographer: true`,
* which rewrites a literal `(c)` as ©.
*/
export function createMarkdownStyles(theme: Theme) {
return {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { defaultHostAppearance } from "@/hosts/appearance";
import {
createRemoteSshHostConnection,
normalizeStoredHostProfile,
orderHostsLocalFirst,
resolveActiveHostServerId,
@@ -141,6 +142,51 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
});
it("normalizes stored Remote SSH connection parameters", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_ssh",
connections: [
{
type: "remoteSsh",
host: " deploy@example.com ",
sshPort: 2222,
daemonPort: 7777,
},
],
});
expect(profile?.connections[0]).toEqual({
id: "ssh:deploy%40example.com:2222:7777",
type: "remoteSsh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
});
});
describe("createRemoteSshHostConnection", () => {
it("keeps optional SSH settings absent", () => {
expect(createRemoteSshHostConnection({ host: "build-box" })).toEqual({
id: "ssh:build-box::",
type: "remoteSsh",
host: "build-box",
});
});
it("rejects invalid SSH destinations and ports", () => {
expect(() => createRemoteSshHostConnection({ host: "" })).toThrow("SSH host is required");
expect(() => createRemoteSshHostConnection({ host: "bad host" })).toThrow(
"SSH host is invalid",
);
expect(() => createRemoteSshHostConnection({ host: "build-box", sshPort: 70000 })).toThrow(
"SSH port must be between 1 and 65535",
);
expect(() => createRemoteSshHostConnection({ host: "build-box", daemonPort: 0 })).toThrow(
"Daemon port must be between 1 and 65535",
);
});
});
describe("upsertHostConnectionInProfiles", () => {
+75
View File
@@ -6,6 +6,11 @@ import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
} from "@getpaseo/protocol/host-connection-schema";
import {
DEFAULT_SSH_DAEMON_PORT,
validatePort,
validateSshHost,
} from "@getpaseo/protocol/ssh-transport";
import {
type HostAppearance,
defaultHostAppearance,
@@ -27,6 +32,14 @@ export interface DirectPipeHostConnection {
path: string;
}
export interface RemoteSshHostConnection {
id: string;
type: "remoteSsh";
host: string;
sshPort?: number;
daemonPort?: number;
}
export interface RelayHostConnection {
id: string;
type: "relay";
@@ -39,6 +52,7 @@ export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RemoteSshHostConnection
| RelayHostConnection;
export type HostLifecycle = Record<string, never>;
@@ -123,6 +137,9 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
if (left.type === "directPipe" && right.type === "directPipe") {
return left.path === right.path;
}
if (left.type === "remoteSsh" && right.type === "remoteSsh") {
return remoteSshConnectionEquals(left, right);
}
if (left.type === "relay" && right.type === "relay") {
return (
left.relayEndpoint === right.relayEndpoint &&
@@ -134,6 +151,17 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return false;
}
function remoteSshConnectionEquals(
left: RemoteSshHostConnection,
right: RemoteSshHostConnection,
): boolean {
return (
left.host === right.host &&
left.sshPort === right.sshPort &&
left.daemonPort === right.daemonPort
);
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
@@ -294,6 +322,35 @@ export function connectionFromListen(listen: string): HostConnection | null {
}
}
export function createRemoteSshHostConnection(input: {
host: string;
sshPort?: number;
daemonPort?: number;
}): RemoteSshHostConnection {
const host = validateSshHost(input.host);
const sshPort = input.sshPort === undefined ? undefined : validatePort(input.sshPort, "SSH port");
const daemonPort =
input.daemonPort === undefined || input.daemonPort === DEFAULT_SSH_DAEMON_PORT
? undefined
: validatePort(input.daemonPort, "Daemon port");
const id = [
"ssh",
encodeURIComponent(host),
sshPort === undefined ? "" : String(sshPort),
daemonPort === undefined ? "" : String(daemonPort),
].join(":");
return {
id,
type: "remoteSsh",
host,
...(sshPort !== undefined ? { sshPort } : {}),
...(daemonPort !== undefined ? { daemonPort } : {}),
};
}
const StoredHostConnectionSchema = z.discriminatedUnion("type", [
z.strictObject({
id: z.string().optional(),
@@ -312,6 +369,13 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
type: z.literal("directPipe"),
path: z.string(),
}),
z.strictObject({
id: z.string().optional(),
type: z.literal("remoteSsh"),
host: z.string(),
sshPort: z.number().optional(),
daemonPort: z.number().optional(),
}),
z.strictObject({
id: z.string().optional(),
type: z.literal("relay"),
@@ -356,6 +420,17 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
const path = connection.path.trim();
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
}
if (connection.type === "remoteSsh") {
try {
return createRemoteSshHostConnection({
host: connection.host,
...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}),
...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}),
});
} catch {
return null;
}
}
if (connection.type === "relay") {
try {
const relayEndpoint = normalizeHostPort(connection.relayEndpoint);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
describe("createAssistantMarkdownParser", () => {
it("renders agent text verbatim", () => {
const parser = createAssistantMarkdownParser();
// The reported bug, plus the substitutions that share its cause.
expect(parser.renderInline("(c) (C) (r) (tm) (p)")).toBe("(c) (C) (r) (tm) (p)");
expect(parser.renderInline("wait for it...")).toBe("wait for it...");
expect(parser.renderInline("a -- b")).toBe("a -- b");
// Smart quotes are off too: a curled quote is not pasteable into a shell.
expect(parser.renderInline(`run --name="my repo"`)).toBe("run --name=&quot;my repo&quot;");
expect(parser.renderInline("it's fine")).toBe("it's fine");
});
it("allows file:// links, unlike every other parser", () => {
const parser = createAssistantMarkdownParser();
expect(parser.render("[open](file:///tmp/a.ts)")).toContain('href="file:///tmp/a.ts"');
});
it("still rejects javascript: links", () => {
const parser = createAssistantMarkdownParser();
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
});
});
@@ -1,13 +1,12 @@
import MarkdownIt from "markdown-it";
import type MarkdownIt from "markdown-it";
import { createMarkdownParser } from "@/utils/markdown-parser";
export function createAssistantMarkdownParser(): MarkdownIt {
const parser = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
});
const parser = createMarkdownParser({ linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
// Assistant messages are the only surface allowed to link into the
// filesystem. Every other parser keeps markdown-it's stricter default.
parser.validateLink = (url: string) =>
url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url);

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