Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a2e8a44848 fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
2026-07-23 20:36:22 +02:00
Mohamed Boudra 81bffbd01a fix(import): harden the desktop import flow 2026-07-20 15:16:03 +02:00
Mohamed Boudra 9519c5ba99 refactor(import): load the import engine as a dependency 2026-07-20 15:05:57 +02:00
Mohamed Boudra 194822b939 fix(server): authorize active workspace config 2026-07-19 07:28:45 +02:00
Mohamed Boudra 2505075beb fix(migrate): reject nested command substitutions 2026-07-19 06:30:11 +02:00
Mohamed Boudra 0c3c61be94 fix(migrate): refresh managed checkout config 2026-07-19 06:09:20 +02:00
Mohamed Boudra 7046b118e9 fix(migrate): preserve adopted workspace config 2026-07-19 05:39:59 +02:00
Mohamed Boudra 3fed2f2cd9 fix(migrate): validate source refs and expansions 2026-07-19 05:02:59 +02:00
Mohamed Boudra 234f0d9b75 test(migrate): cover deleted checkout recreation 2026-07-19 04:28:05 +02:00
Mohamed Boudra 548e15c177 fix(migrate): preserve direct port arithmetic 2026-07-19 03:53:07 +02:00
Mohamed Boudra 6240b2d17c test(server): tolerate delayed Windows handle release 2026-07-19 03:39:32 +02:00
Mohamed Boudra 4e99e4c18c fix(server): release workspace watchers on shutdown 2026-07-19 03:06:48 +02:00
Mohamed Boudra 2f07c5918f test(migrate): compare checkout identities portably 2026-07-19 02:47:23 +02:00
Mohamed Boudra 3e6061b169 fix(client): normalize checkout branch refs 2026-07-19 02:27:46 +02:00
Mohamed Boudra 4ed944db2f fix(migrate): validate live source state 2026-07-19 02:12:29 +02:00
Mohamed Boudra af244d0f02 fix(migrate): share service command semantics 2026-07-19 02:01:39 +02:00
Mohamed Boudra f2124f8cf9 fix(migrate): normalize source references 2026-07-19 01:50:04 +02:00
Mohamed Boudra 152cdcabad fix(migrate): preserve Windows shell semantics 2026-07-19 01:39:15 +02:00
Mohamed Boudra 44da3cdf05 fix(migrate): preserve consistent source state 2026-07-19 01:33:48 +02:00
Mohamed Boudra a8c6c2f4bc fix(migrate): gate automatic source discovery 2026-07-19 01:21:02 +02:00
Mohamed Boudra faccbaae59 fix(migrate): validate workspace connection targets 2026-07-19 01:10:18 +02:00
Mohamed Boudra e61c06706c fix(migrate): preserve source command semantics 2026-07-19 01:03:00 +02:00
Mohamed Boudra c54ddfd07f fix(migrate): preserve checkout and shell semantics 2026-07-19 00:48:41 +02:00
Mohamed Boudra ddaaaa27be fix(desktop): flush migration output before completion 2026-07-19 00:29:50 +02:00
Mohamed Boudra fbd02437dc Merge main into project migration branch 2026-07-19 00:28:33 +02:00
Mohamed Boudra b447a650a7 feat(desktop): migrate existing projects and worktrees 2026-07-19 00:22:17 +02:00
Mohamed Boudra c9bcfa7638 Use safer automatic approval modes by default (#2213)
* feat(providers): default to safer automatic approvals

* fix(providers): preserve defaults on older Codex versions

* test(providers): cover Claude automatic approval default

* fix(providers): preserve Claude defaults on cloud transports

* fix(providers): resolve defaults from launch capabilities
2026-07-18 23:03:39 +02:00
Mohamed Boudra 0cfb9b6b94 Keep sidebar pins visible while reopening (#2210)
* fix(app): keep sidebar pins visible while reopening

Hidden sidebars stop consuming live workspace updates but retain their last rendered entries so the opening animation never exposes an empty list.

* test(app): reuse sidebar pin flow
2026-07-18 22:26:31 +02:00
Mohamed Boudra 8cc2ae0ba4 Resume collected agents before pane actions (#2209)
* fix: resume collected agents before pane actions

* fix(server): keep archived agents closed during pane actions

An archive could win while a collected agent was resuming, then lose when the provider runtime registered. Recheck persisted archive state after registration and close the resumed runtime before any mutation runs.

* fix(server): fence archived agents after shared resume

Protected pane actions could join a resume started by an ordinary loader and skip the archive fence. Recheck persisted lifecycle state for every protected caller so archive always wins before mutation.
2026-07-18 22:15:27 +02:00
Mohamed Boudra 99dc8ddda5 Free resources from idle agents automatically (#2203)
* fix(server): release resources held by idle agents

Keep unarchived agents resumable while closing their provider runtimes after two minutes. Active agent schedules keep runtimes resident.

* fix(server): preserve resumable agent state

* fix(server): resume agents when listing commands

* fix(server): preserve collected agent interactions
2026-07-18 21:21:05 +02:00
Mohamed Boudra 5ea311f243 feat(app): restore recent chat while reconnecting (#2206)
Keep a small non-authoritative view of the last focused chat so the app can paint it before the daemon finishes revalidation. Bound persistence to one focused agent per host and evict whole host entries to limit storage and serialization work.
2026-07-19 03:08:46 +08:00
Mohamed Boudra 2185779d6c fix(settings): highlight changes that apply next turn (#2201)
Permission and thinking changes made during an active turn do not affect that turn. Use setting-specific warnings so the timing is harder to miss.
2026-07-18 18:45:23 +02:00
Mohamed Boudra e0e50c9a8e fix(app): preserve persisted theme styles after startup
Module-level style composites could materialize the temporary adaptive theme before persisted settings loaded. Keep Unistyles reads in render and guard against eager module-scope access.
2026-07-18 17:45:02 +02:00
Mohamed Boudra c0622a7046 feat(website): show real app in homepage hero 2026-07-18 17:43:38 +02:00
45bbb973a3 Switch models from the Command Center (#2147)
* Switch models from the Command Center

Add a model switcher to the Command-K Command Center. Typing surfaces a
flat, filterable list of "Model › Provider › Name" breadcrumb rows with
provider icons:

- Running agent: its own provider's models (a live agent can't change
  provider); selecting calls setAgentModel.
- New draft tab: every available provider's models in one flat list;
  selecting sets provider + model on the draft via a focused-draft
  controller published to a global store (the draft form state is local
  to the composer subtree and otherwise unreachable from the global
  Command Center).

Models only appear once the user starts typing, so the default palette
view is unchanged. Reuses useProvidersSnapshot and the existing
setAgentModel RPC — no protocol changes.

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

* Update packages/app/src/components/command-center.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update packages/app/src/hooks/use-command-center.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* refactor(app): make Command Center extensible

Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection.

* fix(app): save model preference after agent switch

Persist the shared model choice only after the daemon confirms the live agent switched successfully.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-18 16:54:41 +02:00
Mohamed Boudra 7917532716 Keep focused agent timelines live and catch up instantly (#2196)
* fix(app): keep focused agent timelines live

Window focus was conflated with app visibility, so switching OS windows could remove the selected timeline subscription. Separate those signals, grace every visibility-driven removal, and cover retained history until authoritative catch-up completes.

* fix(app): keep timeline catch-up failures non-blocking

* fix(app): surface timeline catch-up failures

* fix(app): preserve file preview refocus

* fix(app): preserve optimistic catch-up flow
2026-07-18 16:30:59 +02:00
Matt Van HornandMatt Van Horn fcaa84f0e4 fix(omp): accept thinkingLevel "max" when importing OMP sessions (#2191)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-18 13:44:34 +00:00
Jason@HND b4518cbf33 fix(server): remove wall-clock timeout for Pi compact RPC (#2181)
Pi compact is a blocking LLM summarization job that often exceeds the
default 30s control-plane timeout on long sessions, causing a false UI
error while the real compact continues. Wait for the RPC response,
process exit, or session close instead.

Also cover the no-timeout lifecycle: null timeout still rejects on
close, and compact waits past 30s for a late success response.

Closes #1946
2026-07-18 21:20:03 +08:00
Mohamed Boudra 72752b7db6 Document workspace-first agent automation (#2192)
* docs: explain workspace-first automation

* docs: make automation examples self-contained
2026-07-18 15:18:05 +02:00
Mohamed Boudra 05d1f838d8 fix(composer): keep message input visible after dictation (#2194)
The voice overlay transition could retain stale native opacity after the app was backgrounded, leaving the mounted composer transparent. Derive both surfaces directly from voice state so they cannot disagree after resume.
2026-07-18 15:15:25 +02:00
nllptrx c97f823236 Fix commit-aware PR resolution across forges 2026-07-18 14:21:10 +02:00
Mohamed Boudra ffe76a7e57 Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation

Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics.

* test(cli): expect canonical schedule cadence

* fix(automation): preserve workspace and schedule compatibility

* fix(automation): preserve compatibility edges

* fix(workspaces): align MCP lifecycle resolution

* fix(workspaces): preserve creation intent

* fix(workspaces): preserve branch and schedule identity
2026-07-18 14:04:48 +02:00
Mohamed Boudra 98f6611362 Fix duplicated and out-of-order agent chat messages (#2185)
* fix(app): keep agent timelines ordered during catch-up

Projected catch-up pages can overlap live rows and arrive after optimistic
prompts. Reconcile canonical projections before the existing stream reducer,
and retain selective subscriptions briefly across quick view switches.

* refactor(app): make optimistic catch-up consumption explicit

* fix(app): preserve catch-up message order

* fix(app): isolate delayed catch-up history

* fix(app): anchor catch-up before live turns

* fix(app): stage complete catch-up history

* fix(app): keep timeline arrays Hermes-safe

* refactor(app): preserve prompt positions during catch-up
2026-07-18 13:49:32 +02:00
paseo-ai[bot] 99da5736db fix: update lockfile signatures and Nix hash [skip ci] 2026-07-18 10:51:30 +00:00
Mohamed Boudra d9abac0f8e chore(server): update Claude Agent SDK (#2189) 2026-07-18 12:43:14 +02:00
Mohamed Boudra 745e8afe45 Make keyboard shortcuts searchable (#2160)
* feat(app): make keyboard shortcuts searchable

* test(app): cover shortcut search empty state

* fix(app): index shortcut modifier aliases
2026-07-18 11:54:49 +02:00
Mohamed Boudra b4a5b6a3ff Add non-Git projects across filesystem mounts (#2187)
* fix(projects): allow non-git folders across mounts

Treat failed Git worktree discovery as non-Git so ordinary directories remain addable. Preserve the underlying diagnostic as a structured warning instead of failing project creation.

* fix(projects): preserve discovery warnings

Propagate checkout context through the remaining worktree discovery calls so fail-open classification retains its structured warning.
2026-07-18 11:49:11 +02:00
Slava Goltser a1de743ef6 fix(app): align thinking section scroll layout with other detail sections (#1884)
* feat(app): make thinking sections scrollable when expanded

* fix(app): align thinking section scroll layout with other detail sections
2026-07-18 03:07:44 +08:00
Mohamed Boudra a414f8ea85 Connect your Paseo daemon to Hub (#2035)
* feat(hub): connect daemons to Paseo Hub

Make Hub an explicit daemon-owned relationship with local-only management and scoped access to Hub-owned executions.

* fix(hub): harden relationship boundaries

* fix(hub): harden relationship lifecycle

* fix(hub): isolate CLI test entrypoint

* fix(hub): run CLI tests from workspace source

* fix(hub): settle failed relationship connections

* fix(hub): resume interrupted owned turns

Provider session rehydration does not continue foreground work lost during daemon shutdown. Persist narrowly scoped Hub execution intent and replay only an interrupted running initial turn.

* fix(hub): harden relationship lifecycle

Keep optional Hub authority from blocking daemon startup, revoke ambiguous enrollments durably, and close owned agents when their relationship no longer exists. Reject remote CLI connect targets before transmitting enrollment authority.

* fix(hub): stop replaying interrupted turns

Daemon restart cannot safely guarantee prompt idempotency across providers. Persist the normal closed session state while retaining Hub relationship, execution, and agent identity.

* fix(hub): fail creates when prompts cannot start

* fix: make Hub lifecycle cleanup deterministic

* fix(hub): preserve fresh enrollment authority

* fix(hub): contain enrollment retry failures

* fix(hub): reject invalid socket transport URLs

* fix(hub): bind socket transport to Hub authority

* fix(hub): close relationship lifecycle gaps

* test(hub): stabilize lifecycle coverage on Windows

* fix(hub): close execution authority races

* fix(hub): return relationship command errors

* fix(app): preserve workspace navigation compatibility

* fix(hub): correct relationship trust boundaries

Authenticated daemon sessions own relationship management regardless of transport. The separate Hub session remains operation-allowlisted, rejects malformed execution inputs, and uses bounded outbound handshakes.

* refactor(hub): authorize execution through sessions

* fix(hub): validate persisted origins

* fix(hub): retain local execution grants

* fix(hub): enforce session scope boundaries

Make session authority explicit and mutable without adding scope negotiation. Fence persisted Hub scopes and retire in-flight execution authority during cleanup and re-enrollment.

* fix(server): preserve main session compatibility
2026-07-17 20:20:57 +02:00
Mohamed Boudra 39cb3dbb9c Treat every added folder as an independent project (#2098)
* fix(projects): detect when projects become Git repositories

Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces.

* fix(projects): preserve metadata across Git read failures

Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata.

* fix(projects): close project update races

* fix(projects): refresh checkout metadata when reopening folders

Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors.

* test(projects): accept Windows aliases for repaired roots

* test(server): retry transient Windows cleanup locks

* fix(projects): propagate project metadata refreshes

Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records.

* fix(projects): refresh worktree source project kind

* fix(projects): preserve exact-folder runtime isolation

* fix(projects): preserve exact cwd across worktree lifecycle

* fix(projects): harden exact-folder worktree flows

* fix(projects): derive exact cwd from matched path identity

* fix(projects): preserve nested worktree lifecycle

* refactor(projects): centralize workspace placement

* test(e2e): verify isolated server ports

* fix(projects): preserve placement reshape compatibility

* fix(projects): validate created worktree placement

* fix(projects): preserve placement through workspace lifecycle

Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.

* fix(worktrees): preserve source checkout root

Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed.

* fix(worktrees): compare filesystem identities

Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity.

* fix(worktrees): centralize path containment

Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check.

* fix(worktrees): remove duplicate ownership discovery

* test(e2e): isolate daemon restart ownership

* fix(worktrees): make lifecycle operations transactional

* fix(workspaces): share git watches by cwd

* test(worktrees): make teardown proof portable

* fix(sync): integrate project updates with directory owner

* fix(workspaces): close reconciliation edge cases

* refactor(sync): remove duplicate project reconciliation

Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation.

* fix(sync): buffer project updates before hydration

Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
2026-07-17 19:30:17 +02:00
Mohamed Boudra 4d2acfbeb5 fix(server): reject imported port offsets 2026-07-17 18:28:10 +02:00
Mohamed Boudra d6bc201f46 fix(app): refresh import availability previews 2026-07-17 18:10:55 +02:00
Mohamed Boudra 5e75e463ec fix(settings): expose remaining import errors 2026-07-17 17:47:16 +02:00
Mohamed Boudra 35d4ae201a fix(settings): tighten conductor service imports 2026-07-17 17:21:23 +02:00
Mohamed Boudra 1977d330ed Reduce workspace, agent, and chat sync traffic (#2028)
* feat(sync): keep live data scoped and current

Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates.

* fix(sync): preserve selective delivery boundaries

Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries.

* test(app): align browser fixtures with runtime contracts

* fix(sync): preserve mixed-client delivery guarantees

* fix(sync): finish paged history before advancing

* fix(sync): replay live deltas after refresh failures

* test(server): model socket capability lookup

* test(app): use platform shortcut for split-pane coverage

* fix(app): keep hidden timelines dormant

* test(app): normalize split-pane shortcut setup

* fix(app): preserve sync state across retries

* fix(app): preserve background sync state across races

* fix directory bootstrap reconciliation edge cases

* fix selective timeline compatibility races

* fix superseded directory sync races

* fix(sync): centralize directory replica ordering

Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources.

* fix(app): keep timeline requests runtime-scoped

* fix(app): close directory bootstrap races
2026-07-17 17:03:34 +02:00
Mohamed Boudra b93d326fb2 fix(settings): preserve conductor migration context 2026-07-17 17:00:30 +02:00
Mohamed Boudra 9505e43925 fix(settings): report remaining import gaps 2026-07-17 16:42:07 +02:00
Mohamed Boudra 2f0e3451de fix(settings): wait for import preview before setup callout 2026-07-17 16:22:10 +02:00
Mohamed Boudra 28cac5859a fix(settings): keep project imports current and complete 2026-07-17 15:56:04 +02:00
Mohamed Boudra 6c99efae52 fix(android): give release builds more memory 2026-07-17 15:48:06 +02:00
Mohamed Boudra 59d9a25a0d fix(settings): handle remaining import edge cases 2026-07-17 15:32:27 +02:00
paseo-ai[bot] 6f753a142d fix: update lockfile signatures and Nix hash [skip ci] 2026-07-17 13:18:56 +00:00
Mohamed Boudra 60462337b6 fix(settings): close project import edge cases 2026-07-17 15:11:04 +02:00
Mohamed Boudra 0bec06c2db chore(release): cut 0.2.0-beta.1 2026-07-17 15:10:06 +02:00
Mohamed Boudra c0f80e2477 Prepare 0.2.0 beta changelog 2026-07-17 15:07:21 +02:00
Mohamed Boudra 293f55afc4 Update ACP provider catalog versions 2026-07-17 15:07:14 +02:00
Mohamed Boudra df2b7cab46 Document release version classification 2026-07-17 15:07:08 +02:00
Mohamed Boudra f44ef807ef fix(settings): harden conductor imports 2026-07-17 14:48:54 +02:00
Mohamed Boudra f030ddefb1 refactor(settings): make project imports pluggable 2026-07-17 14:42:02 +02:00
Mohamed Boudra dfada2a556 fix(app): update provider icon 2026-07-17 14:03:43 +02:00
Mohamed Boudra 263ccc2a19 Merge branch 'main' of github.com:getpaseo/paseo 2026-07-17 13:06:31 +02:00
Mohamed Boudra 388f1d426c Keep agent browser tabs connected across workspace switches (#2156)
* fix(desktop): keep browser tabs connected across workspaces

Electron can replace a guest WebContents when a retained browser tab is reparented. Re-register each attachment and keep background actionability checks running so agent browser tools retain the tab through workspace eviction.\n\nCover the full app, daemon, Electron, and MCP path in the existing desktop CI job.

* fix(desktop): launch Electron E2E reliably on Linux

CI Electron must receive --no-sandbox before the app starts because the hosted runner cannot use Electron's bundled SUID sandbox helper. Forward explicit dev-runner arguments and fail readiness waits as soon as a child exits.

* fix(desktop): preserve active browser on repeated registration

* fix(desktop): keep browser keyboard attachment idempotent

* fix(desktop): wait for E2E bridge readiness

* fix(desktop): close E2E logs after output drains
2026-07-17 19:02:09 +08:00
Mohamed Boudra a7cbf4f61d fix(app): make sidebar reordering respond immediately
Mouse input inherited the touch hold delay. Split activation by input so mouse dragging begins after deliberate movement while touch retains long-press arbitration.
2026-07-17 13:00:47 +02:00
Mohamed Boudra 266d54463b feat(app): highlight sidebar resize handles 2026-07-17 12:27:15 +02:00
Mohamed Boudra f7a6548b68 fix(app): refresh stale config import apply 2026-07-17 11:33:56 +02:00
Mohamed Boudra ab9c9b96a7 fix(server): normalize conductor import paths 2026-07-17 11:31:52 +02:00
Mohamed Boudra 557fc42c89 feat(server): include daemon version in logs (#2155)
Attach the running release to every entry so log history can show when daemon upgrades took effect.
2026-07-17 11:17:21 +02:00
Mohamed Boudra 8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
Mohamed Boudra bce2c50b9e Keep terminal sizing reliable through focus changes (#2154)
* fix(terminal): retry size claims until sent

A focus resize could consume its once-per-focus latch before the renderer handed the size to the client. Keep the claim pending across visibility, connection, and renderer readiness changes, and only commit it after the resize is sent.

* fix(terminal): keep disconnected claims retryable

A retained runtime client can outlive its active connection. Require the connection at delivery time so a delayed resize callback cannot commit a claim that was never sent.

* test(terminal): await PTY resize observation

Prove the terminal starts at 80x24 while blurred, then probe through the daemon until its own reported size matches xterm after focus returns. This avoids racing a one-shot stty sample against the asynchronous resize claim.
2026-07-17 10:07:50 +02:00
Mohamed Boudra 70472dc945 Always install the newest eligible desktop update (#2149)
* Reapply "Always revalidate desktop updates before install"

This reverts commit 623c05aa4d.

* fix(desktop): make update revalidation safe on quit

Updater-triggered quits now bypass normal quit handling, and manifest revalidation gives up after five seconds without allowing a late install.

* fix(desktop): preserve fail-closed update installs

Keep cached updates deferred when quit-time validation is offline, and report validation timeouts separately from superseded releases.

* fix(desktop): preserve updater quit behavior

Automatic installs remain silent without relaunching, updater handoff is bounded, and background preparation failures remain visible without pinning manifest checks.

* fix(desktop): keep AppImage updates manual

Preserve the AppImage safety exemption by skipping ordinary quit-time installation while retaining the explicit Update now path.

* fix(desktop): serialize update preparation

* fix(desktop): recognize macOS updater quit handoff
2026-07-17 10:06:32 +02:00
nllptrxandMohamed Boudra a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00
Mohamed Boudra 04d1ebdce0 Keep browser input from submitting Paseo prompts (#1982)
* fix(desktop): keep browser input out of the composer

Unhandled webview keys could be redispatched into the active host window, allowing agent Enter to submit a draft prompt. Give pages first refusal for ordinary shortcuts and contain automation at the guest boundary.

* fix(desktop): tighten browser shortcut validation

* fix(desktop): respect browser shortcut ownership

* fix(desktop): retain browser keys across windows

* fix(desktop): scope browser webviews by host

* Reshape browser shortcut ownership

Use the browser webview registry as the single guest identity authority, scope browser operations to their host window, publish chord continuations only while pending, and restore focus to the originating browser after command center dismissal.

* Fix host-scoped browser shortcut follow-ups

* Fix browser keyboard review follow-ups

* Fix browser keyboard lifecycle regressions

* Fix browser shortcut review regressions

* Fix desktop browser review regressions

* Fix browser shortcut frame regressions

* Fix rebase integration regressions

* Preserve browser-native shortcut ownership

* Isolate browser shortcuts and automation by context
2026-07-17 14:29:19 +08:00
Christoph Leiter 9f5f5fce62 Fix terminal resize race (#2059) 2026-07-16 23:47:09 +02:00
Mohamed Boudra a622860a3e Keep workspace focus mode scoped and easy to exit (#2151)
* fix(workspace): keep focus mode scoped and easy to exit

Route focus mode through the active workspace so persisted state cannot hide chrome on settings or other screens. Add a visible exit control and keep desktop window chrome aligned and visually quiet.

* fix(app): clarify muted chrome semantics
2026-07-16 23:46:24 +02:00
Ethan Greenfeld d2308f4835 feat(omp): add native OMP provider (#2067)
Pi and OMP share only the JSONL child-process transport while retaining provider-owned launch, RPC, runtime, session, history, and permission behavior. Removes captured fixtures in favor of typed harnesses and real-provider coverage.

Closes #2006
Closes #2060
2026-07-16 23:20:59 +02:00
paseo-ai[bot] d9a0b3e8d8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-16 21:10:04 +00:00
Mohamed Boudra 6db7e53b6e Record the 0.1.110 ACP hotfix release (#2150)
Sync the 0.1.110 release metadata and changelog back to main.
2026-07-16 23:01:31 +02:00
Mohamed Boudra 737f30c339 Make commit history easier to scan and review (#2146)
* feat(app): improve commit history and diff review

* fix(app): keep commit timestamps current

* fix(app): pause hidden commit clocks

* fix(app): reopen commit diff in persistence test
2026-07-17 04:15:04 +08:00
Mohamed Boudra 60855a0f3a feat(website): group changelog patches by minor release
Keep each patch dated and directly linkable while reducing top-level changelog noise.
2026-07-16 22:11:02 +02:00
Mohamed Boudra 623c05aa4d Revert "Always revalidate desktop updates before install"
This reverts commit 7d80fdfd12.
2026-07-16 21:20:57 +02:00
Mohamed Boudra d5baf1a7e6 fix(acp): keep foreground agents running (#2148)
ACP setup and out-of-prompt notifications do not define a turn lifecycle. Treating them as autonomous turns could complete the active run while its prompt was still streaming.
2026-07-16 20:49:00 +02:00
Mohamed Boudra a1cd50c2ae Reimport archived sessions into the current workspace (#2123)
* fix(sessions): reimport archived sessions in their workspace

Archived agent records were treated as active imports, hiding their provider sessions permanently. Workspace-originated imports also discarded their workspace identity and created a duplicate workspace.

* fix(sessions): validate archived session restores

Restore archived imports under their existing Paseo agent identity, reject stale workspace ownership, and gate workspace targeting when the host cannot honor it.

* fix(sessions): roll back failed archived imports

If provider resume or history hydration fails, close any partial runtime, re-archive the provider session, and restore the original stored agent record.

* fix(sessions): validate restored import ownership

* fix(sessions): serialize concurrent restores

* fix(sessions): harden archived import recovery

* fix(sessions): validate archived import placement

* refactor(sessions): centralize provider session imports

Keep workspace placement rollback and stored-agent activation behind their existing owners so Session remains the wire boundary.
2026-07-17 02:25:50 +08:00
Mohamed Boudra 7d80fdfd12 Always revalidate desktop updates before install 2026-07-16 20:17:58 +02:00
Mohamed Boudra 04c71c5890 Style the 0.1.109 update notice as a callout 2026-07-16 20:01:59 +02:00
Mohamed Boudra 721ef03779 Warn desktop users about the 0.1.108 update 2026-07-16 19:54:31 +02:00
Mohamed Boudra ccf29f4c50 Fix sign-in popups in the desktop browser (#2137)
* fix(desktop): keep sign-in popups connected

Turning every window-open request into a workspace tab severed the opener relationship required by popup authentication flows. Keep script-created and POST-backed opens as secured child windows while ordinary links continue to use workspace tabs.

* fix(desktop): keep Shift-click links in workspace tabs

Electron reports both popup windows and Shift-clicked links as new-window. Use popup features or a named target to preserve script popups without bypassing Paseo tab state for ordinary links.

* fix(desktop): tighten popup profile handling

* fix(desktop): preserve popup feature intent

* fix(desktop): match browser popup heuristics
2026-07-16 18:45:32 +02:00
669 changed files with 73201 additions and 13800 deletions
+3 -1
View File
@@ -8,4 +8,6 @@ user-invocable: true
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
+5 -3
View File
@@ -1,11 +1,13 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
+55 -1
View File
@@ -160,10 +160,29 @@ jobs:
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
run: npm run build:desktop -- --publish never --linux --x64 --dir
@@ -181,6 +200,41 @@ jobs:
if-no-files-found: ignore
retention-days: 7
import-electron:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build app and server dependencies
run: |
npm run build:app-deps
npm run build:server
- name: Build Desktop main
run: npm run build:main --workspace=@getpaseo/desktop
- name: Run product import behavior
run: npm run test:import-electron --workspace=@getpaseo/desktop
- name: Upload import behavior diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: import-electron-macos
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
app-tests:
runs-on: ubuntu-latest
env:
+3
View File
@@ -87,6 +87,9 @@
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
// React Native style arrays must read Unistyles proxies during render. Hoisting them to
// satisfy this allocation rule captures the temporary startup theme instead.
"react-perf/jsx-no-new-array-as-prop": "off",
"no-restricted-imports": [
"error",
{
+38
View File
@@ -1,7 +1,45 @@
# Changelog
## 0.2.0-beta.1 - 2026-07-17
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- ACP provider catalog updated to the latest registry versions
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124))
### Fixed
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16
### Fixed
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
## 0.1.109 - 2026-07-16
> **Important update notice**
>
> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.
### Fixed
- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))
+1
View File
@@ -37,6 +37,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
+4
View File
@@ -68,6 +68,10 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
## Forge host trust
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
+41 -19
View File
@@ -10,7 +10,26 @@ initializing → idle → running → idle (or error → closed)
└────────┘ (agent completes a turn, awaits next prompt)
```
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. Subagents are considered independently; collection
does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
### Cancellation
@@ -18,29 +37,32 @@ Cancellation changes lifecycle state only after the provider acknowledges the in
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
## Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
@@ -133,11 +155,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+2
View File
@@ -122,6 +122,8 @@ The flag must be present for both prebuild and Gradle because Gradle starts Metr
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
+79 -28
View File
@@ -54,22 +54,30 @@ The heart of Paseo. A Node.js process that:
All paths are under `packages/server/src/`.
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
for empty projects and without connected clients, then fans metadata changes through the WebSocket
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
not retain non-Git directories.
**Key modules:**
| Module | Responsibility |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| Module | Responsibility |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -83,20 +91,40 @@ it does not depend on the server.
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
code imports from `@getpaseo/client`. Node automation uses
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
selection, and the capability-gated host automation facade shared by the CLI and
published import tools.
### External project import
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
import-package release machinery.
### `packages/app` — Mobile + web client (Expo)
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 (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
- 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
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
The replica cache exists only to paint stale data immediately while the host connects. It does not
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
written order; a single oversized host is omitted rather than partially restored.
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
@@ -107,9 +135,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo workspace create/ls/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree create/ls/archive`
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
- `paseo speech …`
Communicates with the daemon via the same WebSocket protocol as the app.
@@ -126,6 +156,11 @@ Enables remote access when the daemon is behind a firewall.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### Paseo Hub
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
### `packages/desktop` — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
@@ -138,9 +173,25 @@ Electron wrapper for macOS, Linux, and Windows.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
>
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
```text
Human key -> guest WebContents
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
`-- page keydown
|-- page prevents ------> page owns it
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
`-- guest does not handle ----> stop; never redispatch to the host window
```
### `packages/website` — Marketing site
@@ -173,7 +224,7 @@ Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a sess
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -255,14 +306,14 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
| Surface | Key | Source |
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| Surface | Key | Source |
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
+16 -2
View File
@@ -13,7 +13,16 @@ It validates the compositor behavior that unit tests cannot see:
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that initial page window handlers get first refusal, unhandled
shortcuts synchronously suppress editable browser defaults before crossing the host
boundary, shortcuts marked unavailable in editable targets retain the browser field's
native behavior, handlers registered after preload still get first refusal, focused
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
stays in the guest.
Run it with the repo Electron:
@@ -21,9 +30,11 @@ Run it with the repo Electron:
npm run capture-harness --workspace=@getpaseo/desktop
```
Run the browser automation fixture with:
Build the desktop main process before the automation group so its production guest
preload is available:
```bash
npm run build:main --workspace=@getpaseo/desktop
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
@@ -43,6 +54,9 @@ ARIA-like snapshot text includes headings, static text, and controls; refs survi
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
Keyboard containment runs last because the host-composer sentinel intentionally leaves
native focus in the host. It reuses an existing fixture button: adding a test-only control
changes the inline fixture geometry exercised by the earlier actionability checks.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window
+31 -3
View File
@@ -347,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
### OMP profiles and Pi-compatible forks
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
```json
{
@@ -361,6 +361,34 @@ OMP already ships as a built-in provider option. It is disabled by default; enab
}
```
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
```json
{
"agents": {
"providers": {
"omp-work": {
"extends": "omp",
"label": "Oh My Pi (Work)",
"command": ["omp"],
"env": {
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
}
}
}
}
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
@@ -380,7 +408,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
+57 -26
View File
@@ -1,5 +1,29 @@
# Data Model
## Project identity
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
@@ -58,8 +82,8 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
@@ -265,8 +289,8 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
### Nested: ScheduleTarget (discriminated union on `type`)
@@ -422,19 +446,22 @@ Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
emptied duplicate.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
workspace `projectId` is authoritative when it names an active project, regardless of cwd
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
workspace together with its owning project.
---
@@ -444,21 +471,25 @@ emptied duplicate.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
+2
View File
@@ -40,6 +40,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
+2
View File
@@ -374,6 +374,8 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
+190
View File
@@ -0,0 +1,190 @@
# Adding a Git Forge to Paseo
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
shared protocol messages carry neutral/open facts, the server adapter owns
behavior, and the app owns bundled presentation/runtime interpretation.
The maintainer litmus test is the rule of thumb:
> Adding a new forge means adding files in a new directory/module that implement
> an interface, plus one entry in the centralized registry/manifest for that
> package.
## The Three Registrations
For forge `acme`, the expected end state is:
1. **Protocol manifest** - optional, only when the forge should be presented by
shared manifest data. Add one `ForgeDefinition` to
`packages/protocol/src/forge-manifest.ts`.
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
implementing `ForgeService`, any adapter-owned fact types/guards/constants
beside it, and one `defaultForgeRegistry` entry in
`packages/server/src/services/forge-registry.ts`.
3. **App modules** - a forge splits into a pure logic half and a view half so
logic consumers (URL builders, merge-capability, native checks, and the
Node-based e2e harness) never pull the client rendering stack:
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
optional `facts` (schema, merge-capability, native-check fallbacks). No
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
`packages/app/src/git/forges/index.ts`.
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
under `packages/app/src/components/icons/`), optional `brandColor`, optional
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
`packages/app/src/git/forges/view.ts`.
There should be no protocol typed-union arm, no central app icon/color/url/facts
map, and no central server union of known forge facts.
## Protocol
`forgeSpecific` on PR status is an open envelope:
```ts
z.object({ forge: z.string() }).passthrough();
```
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
"gitea"` when they share the same facts shape, while top-level `status.forge`
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
Protocol does not validate per-forge fact fields. Consumers that understand a
facts family validate at runtime with their own schema/guard. Unknown or
schema-mismatched facts render neutrally instead of failing the whole message
parse. This is the version-skew win: an old client can receive facts from a
newer forge and still show the PR/MR in a neutral state.
Shipped GitHub compatibility stays separate:
- `status.github` remains accepted for released peers.
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
into `status.github` for older clients.
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
actually produce the state.
## Server
The server-wide status type only promises:
```ts
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
```
Adapter-owned files define the typed shapes and guards, for example
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
strong internal types for construction and command guards, but shared server
code must not grow a central list of forge fact arms.
Register the adapter in `defaultForgeRegistry` with:
- `createService`
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
## App
Each app forge splits into two modules so pure logic never imports the client
rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar`
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
- `id`
- `icon`
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
- optional `paneContributions`
Two registries live under `packages/app/src/git/forges/`:
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
drives icon/color lookup and PR-pane contributions. Logic consumers must import
the logic registry only — importing the view registry (or a `.view.tsx` module)
from a logic path pulls react-native and breaks the Node-based e2e harness.
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
call sites use `withUnistyles` and a `uniProps` mapping such as:
```ts
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
```
Facts modules use one source of truth: a Zod schema. Helpers like
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
derive guards from `schema.safeParse` and re-parse before invoking typed
derivers/renderers. That keeps typed derivers away from the open wire envelope.
## Checklist
To add `acme`:
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
label, nouns, icon kind, sign-in CLI, or cloud hosts.
2. Add `acme-service.ts` implementing `ForgeService`.
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
4. Add one `defaultForgeRegistry` entry.
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
`packages/app/src/git/forges/acme.view.tsx` (view).
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
7. Add/update the icon component only if the client bundle should show a brand
mark.
8. If the forge's CI/data model does not fit an existing required
`ForgeService` field, widen the shared interface (plus the protocol schema
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
`workflowRunId` as the alternative address. Expect this step to touch
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
adapters. Widening a shared field is not forge-local: it also affects the
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
`forgeCheckDetails`), so verify every consumer rather than assuming the change
only reaches the new adapter.
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
checkout PR schema, app forge URL/presentation tests, app merge capability,
and any PR-pane native data tests touched.
Run `npm run typecheck` after each implementation slice. If protocol or client
declarations are stale, run `npm run build:client`; if server/CLI declarations
are stale, run `npm run build:server`.
## Gotchas
- GitHub is a normal registry entry plus released compatibility shims. Keep all
real shims tagged with `COMPAT(name)`.
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
top-level brand is Forgejo or Codeberg.
- Brand icons are bundled React components, so they cannot come from protocol
manifest data.
- Source URL grammars are app-side because blob/tree path syntax is
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
rather than constructing a wrong URL.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.
+14 -10
View File
@@ -2,39 +2,43 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
+72
View File
@@ -0,0 +1,72 @@
# Paseo Hub relationship
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect <url> --token <token>` from the daemon machine.
## Connection and authority
The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub.
The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional
encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch,
or reconnects.
The daemon persists a relationship ID and private connection credential before enrollment. The
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
## Session grants and execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
shape.
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
response returns the existing agent and its current state; there is no separate reconciliation RPC.
Transient stream frames are not durably replayed.
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
replays the original prompt. A duplicate create returns the existing agent without starting another
turn.
Hub creates use the same agent creation path as trusted clients. They may select any existing
worktree target shape and may request `autoArchive`. Worktree creation and terminal auto-archive use
the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path.
## Disconnect and revocation
Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter.
Daemon restart loads the same relationship and credential and reconnects without another enrollment
ceremony.
Hub authentication rejection or close code `4403` permanently revokes the local relationship. The
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
scopes, and a sanitized reason for status reporting.
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
response was lost. `--force` removes local authority immediately and warns that remote revocation may
still be pending.
## Cross-repository compatibility
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
+9
View File
@@ -37,6 +37,15 @@ npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
The parity test catches missing keys across English and every supported locale resource.
## Forge-Variant Copy
Strings that vary by git forge follow a two-tier rule:
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
## Migration Order
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
+1 -1
View File
@@ -73,7 +73,7 @@ Anyone who builds software:
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
+3 -3
View File
@@ -16,7 +16,7 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -32,9 +32,9 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
+45 -16
View File
@@ -12,6 +12,8 @@ A release has exactly two steps. The agent does the first, the user authorizes t
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
@@ -36,16 +38,40 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
## Standard release (patch)
## Release version decision
Before running any stable patch release command:
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
diff. The highest-impact change determines the version:
- **Minor** — a user would experience the release as a significant upgrade. This
includes substantial new workflows, providers, forges, platforms, integrations,
or meaningful expansions of existing capabilities. Foundational internal work
also qualifies when it materially changes reliability, performance,
compatibility, deployment, or operation; diff size alone does not.
- **Patch** — fixes, polish, small enhancements, and reliability or performance
improvements within existing capabilities. Follow-up corrections to a minor
release are patches.
The release agent selects patch or minor during preparation and presents the
target version with the changelog for approval. Agents never select a major
version autonomously. A major release requires an explicit user instruction and
approval; Paseo remains on major version zero until that deliberate decision.
Version bumps are never used to retry a failed build. Retry the existing version
as described in **Fixing a failed release build**.
## Standard release (stable)
Before running any stable release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
```bash
# Run exactly one, matching the approved decision:
npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
@@ -54,8 +80,6 @@ The Docker workflow builds images from the checked-out source tree on pull reque
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
## Manual step-by-step
@@ -63,7 +87,9 @@ Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fl
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
# Run exactly one approved version command:
npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -71,7 +97,8 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
npm run release:beta:patch # Start the next patch beta line
npm run release:beta:minor # Start the next minor beta line
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
@@ -107,7 +134,7 @@ Updater clients only discover a release through those `.yml` manifests, so there
### Default behavior
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
@@ -127,7 +154,7 @@ gh workflow run desktop-rollout.yml \
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
@@ -165,17 +192,17 @@ gh workflow run desktop-release.yml \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
@@ -463,7 +490,8 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
@@ -472,11 +500,12 @@ Betas are checkpoints along the way; the entry is the single record for the jump
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green
+14 -12
View File
@@ -3,14 +3,14 @@
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
checkout.forge.set_auto_merge.request;
checkout.forge.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Namespace segment: `forge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
@@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
@@ -33,7 +33,7 @@ Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
@@ -45,7 +45,7 @@ Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
@@ -58,14 +58,16 @@ Responses put correlated result data under `payload`:
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
## Forge Namespacing
Provider-specific behavior belongs under the provider segment:
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
- `checkout.forge.*` for operations whose request/response shape is genuinely
forge-neutral and whose implementation dispatches through the forge resolver.
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
callers migrate to the neutral `checkout.forge.*` shape
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
## Compatibility
+20
View File
@@ -124,6 +124,16 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Browser tab bridge regression
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
```bash
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
```
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
@@ -146,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
ineligible-host UI:
```bash
npm run test:import-electron --workspace=@getpaseo/desktop
```
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
@@ -167,6 +186,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
## Agent authentication in tests
+33
View File
@@ -51,9 +51,42 @@ When a client resumes with a known cursor, it catches up after that cursor to co
When a client resumes without a cursor, it fetches the latest tail page.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
After grace has expired, a retained timeline stays covered when revisited until authoritative
catch-up completes; cached partial output is never presented as current history.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
version.
## Projected pages reconcile with live presentation
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
rows already received live—for example, a tool call retained at its original display position while
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
history. This keeps late history before a newly submitted prompt without duplicating an
acknowledged prompt.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`
+24
View File
@@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
### Do Not Materialize Styles At Module Scope
Never read a Unistyles style property into a module-level constant. This includes cached arrays:
```tsx
// Wrong: evaluated while the app may still be using the temporary system theme.
const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
// Right: each style proxy is read when this view renders.
<View style={[settingsStyles.row, settingsStyles.rowBorder]} />;
```
Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A
module-level read can therefore materialize the light style before a persisted dark theme is
active. If the view mounts after that theme change, React Native receives the stale light object;
Unistyles registers the node for future changes but does not retroactively replace its initial
props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly
this reason.
Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule.
Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside
JSX, create the array inside the component or in a `useMemo` that first runs when the component
mounts—never at module evaluation time.
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
+1 -1
View File
@@ -1 +1 @@
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
+95 -66
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -31726,7 +31726,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
@@ -31891,6 +31890,12 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/srvx": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
@@ -35162,7 +35167,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36181,12 +36186,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.109",
"@getpaseo/protocol": "0.1.109",
"@getpaseo/server": "0.1.109",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/server": "0.2.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36432,24 +36437,27 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.1.109",
"@getpaseo/relay": "0.1.109",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/ws": "^8.5.14",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/import": "0.1.0-beta.2",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -36457,6 +36465,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",
@@ -36467,6 +36476,26 @@
"wait-on": "8.0.5"
}
},
"packages/desktop/node_modules/@getpaseo/import": {
"version": "0.1.0-beta.2",
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"smol-toml": "^1.6.0",
"sql.js": "^1.14.1"
},
"bin": {
"paseo-import": "dist/bin.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@getpaseo/client": ">=0.2.0-beta.2"
}
},
"packages/desktop/node_modules/@types/node": {
"version": "24.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
@@ -36689,7 +36718,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37585,7 +37614,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37817,7 +37846,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37830,7 +37859,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38048,15 +38077,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.109",
"@getpaseo/highlight": "0.1.109",
"@getpaseo/protocol": "0.1.109",
"@getpaseo/relay": "0.1.109",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/highlight": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38100,22 +38129,22 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz",
"integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==",
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz",
"integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -38123,10 +38152,10 @@
"zod": "^4.0.0"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz",
"integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz",
"integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==",
"cpu": [
"arm64"
],
@@ -38136,10 +38165,10 @@
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz",
"integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz",
"integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==",
"cpu": [
"x64"
],
@@ -38149,10 +38178,10 @@
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz",
"integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz",
"integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==",
"cpu": [
"arm64"
],
@@ -38162,10 +38191,10 @@
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz",
"integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz",
"integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==",
"cpu": [
"arm64"
],
@@ -38175,10 +38204,10 @@
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz",
"integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz",
"integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==",
"cpu": [
"x64"
],
@@ -38188,10 +38217,10 @@
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz",
"integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz",
"integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==",
"cpu": [
"x64"
],
@@ -38201,10 +38230,10 @@
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz",
"integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz",
"integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==",
"cpu": [
"arm64"
],
@@ -38214,10 +38243,10 @@
"win32"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz",
"integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==",
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz",
"integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==",
"cpu": [
"x64"
],
@@ -38593,7 +38622,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"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.1.109",
"version": "0.2.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,72 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
await createFeatureCommit(workspace.repoPath);
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
await page.setViewportSize({ width: 480, height: 900 });
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
cwd: repoPath,
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
_electron as electron,
expect,
test,
type ElectronApplication,
type Page,
} from "@playwright/test";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
test.setTimeout(180_000);
const repoRoot = path.resolve(__dirname, "../../..");
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
let electronApp: ElectronApplication;
test.beforeAll(async () => {
installation = await createConductorInstallation();
});
test.afterAll(async () => {
await electronApp?.close();
rmSync(installation.root, { recursive: true, force: true });
});
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
electronApp = await launchProduct();
const page = await electronApp.firstWindow();
await openIntegrations(page);
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
await openImport(page);
await expect(
page.getByText("Paseo will register valid repositories", { exact: false }),
).toBeVisible();
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
await expect(page.getByTestId("import-output")).toContainText(
`Registered project ${installation.repo}.`,
);
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
await page.getByTestId("import-done").click();
writeFileSync(installation.databasePath, "not a sqlite database");
await openImport(page);
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
await page.getByTestId("import-done").click();
await stopImportHost();
await page.getByRole("button", { name: "General", exact: true }).click();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
const row = page.getByTestId("conductor-import-row");
await expect(row).toContainText("Start the Desktop-managed host before importing.");
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
});
async function launchProduct(): Promise<ElectronApplication> {
const metroPort = requiredEnvironment("E2E_METRO_PORT");
return electron.launch({
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
env: {
...process.env,
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
HOME: installation.home,
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
},
});
}
async function openIntegrations(page: Page): Promise<void> {
const settings = page.getByRole("button", { name: "Settings", exact: true });
await expect(settings).toBeVisible({ timeout: 90_000 });
await settings.click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
}
async function openImport(page: Page): Promise<void> {
const button = page
.getByTestId("conductor-import-row")
.getByRole("button", { name: "Import", exact: true });
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("import-sheet")).toBeVisible();
}
async function createConductorInstallation(): Promise<{
root: string;
home: string;
repo: string;
databasePath: string;
}> {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
const home = path.join(root, "home");
const repo = path.join(root, "repo");
mkdirSync(home, { recursive: true });
execFileSync("git", ["init", "-b", "main", repo]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
writeFileSync(path.join(repo, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repo,
});
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
mkdirSync(databaseDirectory, { recursive: true });
const databasePath = path.join(databaseDirectory, "conductor.db");
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
execFileSync(
process.execPath,
[
"-e",
`const fs = require("node:fs");
const initSqlJs = require("sql.js");
(async () => {
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
const databasePath = process.argv[1];
const repo = process.argv[2];
const database = new SQL.Database(fs.readFileSync(databasePath));
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
database.run("DELETE FROM workspaces");
fs.writeFileSync(databasePath, database.export());
database.close();
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
databasePath,
repo,
],
{ cwd: repoRoot },
);
return { root, home, repo: realpathSync(repo), databasePath };
}
async function stopImportHost(): Promise<void> {
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
process.kill(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch {
return;
}
}
throw new Error(`Import host ${pid} did not stop.`);
}
function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}
@@ -0,0 +1,19 @@
import { test } from "./fixtures";
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
test.describe("Directory bootstrap correctness", () => {
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
page,
}) => {
test.setTimeout(180_000);
const scenario = await DirectoryBootstrapScenario.open(page);
try {
await scenario.expectDirectoryStarts(1);
await scenario.stayConnectedWithoutRefetchAndApplyDeltas();
await scenario.disconnectMutateAndReconnect();
await scenario.expectVisibleReconciliationAndNavigateAgent();
} finally {
await scenario.cleanup();
}
});
});
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
+68 -31
View File
@@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
interface WaitForServerOptions {
export interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
@@ -22,6 +22,8 @@ interface WaitForServerOptions {
getRecentOutput?: () => string;
}
type ServerProbe = (host: string, port: number) => Promise<void>;
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -75,7 +77,25 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
async function connectToServer(host: string, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
}
async function waitForServer(
port: number,
options: WaitForServerOptions,
probe: ServerProbe = connectToServer,
): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
let lastConnectionError: unknown = null;
@@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
await probe(host, port);
return;
} catch (error) {
lastConnectionError = error;
@@ -116,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
);
}
async function probeMetro(host: string, port: number): Promise<void> {
const response = await fetch(`http://${host}:${port}/status`, {
signal: AbortSignal.timeout(1000),
});
const body = (await response.text()).trim();
if (response.status !== 200 || body !== "packager-status:running") {
throw new Error(
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
);
}
}
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
await waitForServer(port, options, probeMetro);
}
function parseRelayStartupFailure(line: string): string | null {
const clean = stripAnsi(line);
if (/Address already in use/i.test(clean)) {
@@ -556,13 +582,19 @@ async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<nu
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
interface RelayPorts {
relayPort: number;
inspectorPort: number;
}
async function startRelay(excludedPorts: Set<number>): Promise<RelayPorts> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePortExcluding(excludedPorts);
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -576,6 +608,10 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
"127.0.0.1",
"--port",
String(relayPort),
"--inspector-ip",
"127.0.0.1",
"--inspector-port",
String(inspectorPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
@@ -590,7 +626,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
try {
await awaitRelayReady(relayProcess, relayPort, state, buffer);
return relayPort;
return { relayPort, inspectorPort };
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
@@ -673,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_LISTEN:
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
@@ -767,12 +805,19 @@ export default async function globalSetup() {
await logSpeechHarnessConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro({
metroPort,
daemonPort: port,
buffer: metroLineBuffer,
});
await waitForMetro(metroPort, {
label: "Metro web server",
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
});
const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort]));
daemonProcess = startDaemon({
port,
relayPort,
@@ -783,19 +828,11 @@ export default async function globalSetup() {
buffer: daemonLineBuffer,
});
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
]);
await waitForServer(port, {
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
});
const offer = await waitForPairingOfferFromDaemon({
port,
@@ -809,7 +846,7 @@ export default async function globalSetup() {
process.env.E2E_PASEO_HOME = paseoHome;
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`,
);
return async () => {
@@ -26,13 +26,14 @@ interface E2EDaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
function resolveDaemonWsUrl(): string {
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
function resolveDaemonWsUrl(port?: number): string {
return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`;
}
export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
}
/**
@@ -45,7 +46,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
): Promise<ClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
const client = new DaemonClient({
url: resolveDaemonWsUrl(),
url: resolveDaemonWsUrl(options.port),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
-159
View File
@@ -1,159 +0,0 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
import { withDisabledE2ESpeechEnv } from "./speech-env";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
* persisted state reloads and existing clients can reconnect. This exercises the
* post-restart rehydration path (the daemon rebuilding workspace/agent links
* from disk), which is where the worktree-branch regression lives.
*
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
* handle in module scope we can't reach from a spec. Instead we drive it the
* same way an operator would: read the supervisor PID from
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
* worker and releases the lock), wait for the port to free, then re-spawn the
* supervisor with the identical environment globalSetup used. The relay and
* Metro processes are untouched, so we reuse their already-published ports.
*
* This NEVER targets the developer daemon: the port comes from
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
* home globalSetup created.
*/
function getEnvOrThrow(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
}
return value;
}
async function readSupervisorPid(paseoHome: string): Promise<number> {
const pidPath = path.join(paseoHome, "paseo.pid");
const content = await readFile(pidPath, "utf8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number") {
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
}
return parsed.pid;
}
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve(true);
});
socket.setTimeout(1000, () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => resolve(false));
});
}
async function waitUntil(
predicate: () => Promise<boolean> | boolean,
options: { timeoutMs: number; label: string },
): Promise<void> {
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
}
function spawnSupervisor(args: {
paseoHome: string;
port: string;
relayPort: string;
metroPort: string;
editorRecordPath: string;
}): ChildProcess {
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
// Run the supervisor through the resolved tsx CLI under the current node
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const env = withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
});
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
}
});
// Detach our handles so the spawned supervisor outlives this spec process and
// is reaped by globalSetup's cleanup (the original process tree), not us.
child.unref();
return child;
}
export async function restartTestDaemon(): Promise<void> {
const port = getE2EDaemonPort();
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
const editorRecordPath =
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
const pid = await readSupervisorPid(paseoHome);
process.kill(pid, "SIGTERM");
await waitUntil(() => !isPidRunning(pid), {
timeoutMs: 15_000,
label: `supervisor PID ${pid} to exit`,
});
await waitUntil(async () => !(await isPortListening(Number(port))), {
timeoutMs: 15_000,
label: `port ${port} to free`,
});
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
await waitUntil(async () => isPortListening(Number(port)), {
timeoutMs: 30_000,
label: `restarted daemon to listen on port ${port}`,
});
}
@@ -0,0 +1,114 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
export interface DirectoryBootstrapCounts {
agents: number;
workspaces: number;
}
export interface DirectoryRequestStartCounts {
subscribed: DirectoryBootstrapCounts;
unsubscribed: DirectoryBootstrapCounts;
total: DirectoryBootstrapCounts;
}
interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
} catch {
return null;
}
}
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
if (request.page?.cursor) return null;
if (request.type === "fetch_agents_request") return "agents";
if (request.type === "fetch_workspaces_request") return "workspaces";
return null;
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) return;
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
const directory = directoryForRequest(request);
if (directory) {
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
}
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) return;
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
unsubscribed: { ...directoryStarts.unsubscribed },
total: { ...directoryStarts.total },
};
},
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
};
}
@@ -0,0 +1,155 @@
import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
import { getServerId } from "./server-id";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
interface SeededDirectoryAgent {
id: string;
title: string;
}
async function createRunningMockAgent(
workspace: SeededWorkspace,
title: string,
): Promise<SeededDirectoryAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "five-minute-stream",
initialPrompt: `Keep ${title} running for directory synchronization.`,
});
const running = await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
expect(running.status).toBe("running");
return { id: agent.id, title };
}
async function openCommandCenter(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open command center" }).click();
}
export class DirectoryBootstrapScenario {
private readonly workspaces: SeededWorkspace[] = [];
private disconnectedWorkspace: SeededWorkspace | null = null;
private disconnectedAgent: SeededDirectoryAgent | null = null;
private constructor(
private readonly page: Page,
private readonly gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {}
static async open(page: Page): Promise<DirectoryBootstrapScenario> {
const gate = await installDaemonWebSocketGate(page);
const scenario = new DirectoryBootstrapScenario(page, gate);
const workspace = await scenario.seedWorkspace("directory-bootstrap-initial-");
const agent = await createRunningMockAgent(workspace, "Initial directory agent");
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, workspace.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: agent.title, exact: true })).toBeVisible();
return scenario;
}
async expectDirectoryStarts(expectedPerDirectory: number): Promise<void> {
await expect
.poll(() => this.gate.getDirectoryRequestStartCounts())
.toEqual({
subscribed: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
});
}
async stayConnectedWithoutRefetchAndApplyDeltas(): Promise<void> {
const workspace = await this.seedWorkspace("directory-bootstrap-background-");
const agent = await createRunningMockAgent(workspace, "Background directory agent");
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await this.page.keyboard.press("Escape");
await this.expectDirectoryStarts(1);
}
async disconnectMutateAndReconnect(): Promise<void> {
await this.gate.drop();
await expectReconnectingToastVisible(this.page);
this.disconnectedWorkspace = await this.seedWorkspace("directory-bootstrap-reconnect-");
this.disconnectedAgent = await createRunningMockAgent(
this.disconnectedWorkspace,
"Reconnected directory agent",
);
await expect(
this.page.getByText(this.disconnectedWorkspace.projectDisplayName, { exact: true }),
).toHaveCount(0);
await expect(this.page.getByText(this.disconnectedAgent.title, { exact: true })).toHaveCount(0);
this.gate.restore();
await expectReconnectingToastGone(this.page);
await this.expectDirectoryStarts(2);
}
async expectVisibleReconciliationAndNavigateAgent(): Promise<void> {
const workspace = this.requireDisconnectedWorkspace();
const agent = this.requireDisconnectedAgent();
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await agentLink.click();
await expect(this.page).toHaveURL(
new RegExp(
`/workspace/${workspace.workspaceId}/agent/${agent.id}|/workspace/${workspace.workspaceId}`,
),
);
await expect(this.page.getByRole("button", { name: agent.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
const pings = this.gate.getClientRequestCount("ping");
await expect
.poll(() => this.gate.getClientRequestCount("ping"), { timeout: 30_000 })
.toBeGreaterThan(pings);
await this.expectDirectoryStarts(2);
}
async cleanup(): Promise<void> {
this.gate.restore();
await Promise.all(this.workspaces.map((workspace) => workspace.cleanup()));
}
private async seedWorkspace(prefix: string): Promise<SeededWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: prefix });
this.workspaces.push(workspace);
return workspace;
}
private requireDisconnectedWorkspace(): SeededWorkspace {
if (!this.disconnectedWorkspace) throw new Error("Reconnect workspace was not seeded.");
return this.disconnectedWorkspace;
}
private requireDisconnectedAgent(): SeededDirectoryAgent {
if (!this.disconnectedAgent) throw new Error("Reconnect agent was not seeded.");
return this.disconnectedAgent;
}
}
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -85,43 +86,61 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_RELAY_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});
const spawnDaemon = async (): Promise<ChildProcess> => {
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_RELAY_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, child);
return child;
} catch (error) {
await stopProcess(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
);
}
};
let child: ChildProcess;
try {
await waitForServer(port, child);
child = await spawnDaemon();
} catch (error) {
await stopProcess(child);
await rm(paseoHome, { recursive: true, force: true });
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
);
throw error;
}
let closed = false;
return {
serverId,
port,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await stopProcess(child);
await rm(paseoHome, { recursive: true, force: true });
},
+4 -1
View File
@@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
export async function connectNewWorkspaceDaemonClient(options?: {
port?: number;
}): Promise<NewWorkspaceDaemonClient> {
return connectDaemonClient<NewWorkspaceDaemonClient>({
clientIdPrefix: "app-e2e-new-workspace",
port: options?.port,
});
}
+2 -1
View File
@@ -158,10 +158,11 @@ export interface SeedDaemonClient {
killTerminal(terminalId: string): Promise<{ error: string | null }>;
}
export async function connectSeedClient(): Promise<SeedDaemonClient> {
export async function connectSeedClient(options?: { port?: number }): Promise<SeedDaemonClient> {
return connectDaemonClient<SeedDaemonClient>({
clientIdPrefix: "seed",
appVersion: loadAppVersion(),
port: options?.port,
});
}
+7
View File
@@ -39,6 +39,13 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
export async function pinWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
const pinItem = page.getByTestId(`sidebar-workspace-menu-pin-${serverId}:${workspaceId}`);
await expect(pinItem).toBeVisible({ timeout: 10_000 });
await pinItem.click();
}
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
@@ -0,0 +1,107 @@
import { expect, type Page } from "@playwright/test";
type WebSocketMessage = string | Buffer;
interface SessionMessage {
type?: unknown;
payload?: unknown;
}
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: SessionMessage;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
} catch {
return null;
}
}
export function observeTimelineSubscriptions(page: Page) {
let acknowledgedAgentIds: string[] | null = null;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
const message = readSessionMessage(payload);
if (message?.type !== "agent.timeline.set_subscription.response") return;
const response = message.payload as { agentIds?: unknown } | undefined;
if (!Array.isArray(response?.agentIds)) return;
acknowledgedAgentIds = response.agentIds.filter(
(agentId): agentId is string => typeof agentId === "string",
);
});
});
return {
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
const expected = [...new Set(agentIds)].sort();
await expect
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
.toEqual(expected);
},
};
}
interface AssistantFrameState {
active: boolean;
lastText: string | null;
snapshots: string[];
}
export async function observeLastAssistantFrames(page: Page) {
await page.evaluate(() => {
const state: AssistantFrameState = {
active: true,
lastText: null,
snapshots: [],
};
const sample = () => {
const hasPaintedHistoryOverlay = Array.from(
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
).some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
if (!hasPaintedHistoryOverlay) {
const messages = Array.from(
document.querySelectorAll('[data-testid="assistant-message"]'),
).filter((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const text = messages.at(-1)?.textContent?.trim() ?? "";
if (text && text !== state.lastText) {
state.lastText = text;
state.snapshots.push(text);
}
}
if (state.active) requestAnimationFrame(sample);
};
Object.assign(window, { __assistantFrameState: state });
requestAnimationFrame(sample);
});
return {
async stop(): Promise<string[]> {
return page.evaluate(
() =>
new Promise<string[]>((resolve) => {
requestAnimationFrame(() => {
const state = (
window as typeof window & { __assistantFrameState?: AssistantFrameState }
).__assistantFrameState;
if (!state) {
resolve([]);
return;
}
state.active = false;
resolve([...state.snapshots]);
});
}),
);
},
};
}
+1 -1
View File
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
page,
gitlabRemoteProject,
}) => {
expect(gitlabRemoteProject.name).toBe("acme/app");
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);
@@ -59,7 +59,7 @@ async function closeTopSheet(page: Page) {
async function closeSheetByHeaderButton(page: Page, testId: string) {
const sheet = page.getByTestId(testId);
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
await sheet.getByLabel("Close", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
+26
View File
@@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => {
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
await gotoAppShell(page);
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
const search = page.getByPlaceholder("Search shortcuts");
await search.fill("command+n");
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
await search.fill("interrupt");
await expect(dialog.getByText("Interrupt agent", { exact: true })).toBeVisible();
await expect(dialog.getByText("New workspace", { exact: true })).toHaveCount(0);
await search.fill("no matching shortcut");
await expect(dialog.getByText("No results found", { exact: true })).toBeVisible();
await search.fill("");
await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible();
});
test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
+71
View File
@@ -0,0 +1,71 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
async function rowTestIds(rows: Locator) {
return rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
async function visibleBoundingBox(row: Locator) {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible draggable row");
return box;
}
async function quickDragFirstRowAfterSecond(rows: Locator) {
await expect(rows).toHaveCount(2);
const before = await rowTestIds(rows);
const sourceBox = await visibleBoundingBox(rows.nth(0));
const targetBox = await visibleBoundingBox(rows.nth(1));
const page = rows.page();
const source = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 };
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(source.x, source.y + 7);
await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
try {
const secondWorkspace = await firstProject.client.createWorkspace({
source: {
kind: "directory",
path: firstProject.repoPath,
projectId: firstProject.projectId,
},
title: "Second workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to seed a second workspace");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]'));
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
);
} finally {
await firstProject.cleanup();
await secondProject.cleanup();
}
});
@@ -0,0 +1,27 @@
import { expect, test, type Page } from "./fixtures";
test.use({ viewport: { width: 1600, height: 900 } });
async function expectBorderHighlight(page: Page, testID: string) {
const handle = page.getByTestId(testID);
await expect(handle).toBeVisible();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
await handle.hover();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
const highlight = page.getByTestId(`${testID}-highlight`);
await expect(highlight).toBeVisible();
await expect(highlight).toHaveCSS("width", "1px");
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
await workspace.navigateTo();
await expectBorderHighlight(page, "left-sidebar-resize-handle");
await page.getByTestId("workspace-explorer-toggle").first().click();
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
});
+36 -7
View File
@@ -6,6 +6,7 @@ import {
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "./helpers/sidebar";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
@@ -46,24 +47,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
const projectName = path.basename(workspace.repoPath);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarProject(page, projectName);
await waitForSidebarWorkspace(page, workspace.workspaceId);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: "test-owner/test-repo" })
.filter({ hasText: projectName })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
await expect(projectRow).not.toContainText("test-owner/test-repo");
} finally {
await workspace.cleanup();
}
@@ -96,21 +98,24 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-header-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
const projectName = path.basename(workspace.repoPath);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarProject(page, projectName);
await waitForSidebarWorkspace(page, workspace.workspaceId);
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: "test-owner/test-repo",
subtitle: projectName,
});
} finally {
await workspace.cleanup();
@@ -164,6 +169,30 @@ test.describe("Mobile sidebar panelState transition", () => {
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
});
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
try {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(row).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
});
test.describe("Half-screen desktop layout", () => {
@@ -0,0 +1,170 @@
import { test, expect, type Page } from "./fixtures";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import { getTerminalBufferText } from "./helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
import { getServerId } from "./helpers/server-id";
/**
* Regression: a terminal created while the window is unfocused must still claim its PTY size
* once focus returns.
*
* The PTY is only ever resized by an explicit client claim (`terminal_input` resize). A freshly
* mounted terminal starts its claim from terminal-pane's pane-focus reflow effect. Previously, if
* that claim was emitted while the app was not actively visible, `handleTerminalResize` dropped
* it without a retry, leaving the PTY at 80x24 while xterm rendered the real pane size.
*
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
* one and switch to a different app while it spawns. We stage the blur deterministically by
* stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events)
* instead of racing real OS focus.
*
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
* daemon-side — clicking or typing in the pane would fire the focus-claim path and mask the bug.
*/
/**
* Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through
* `document.hasFocus()` plus the window focus/blur events.
*
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
* calling bringToFront() leaves the first page at `hasFocus() === true`, `visibilityState
* === "visible"`, and fires no focus/blur events at all — so there is no way to produce a real
* blur from Playwright. This stubs the environment signal, not the app: the daemon, the
* WebSocket, the terminal, and every code path under test stay real.
*/
async function setWindowFocused(page: Page, focused: boolean): Promise<void> {
await page.evaluate((isFocused) => {
Object.defineProperty(document, "hasFocus", {
configurable: true,
value: () => isFocused,
});
window.dispatchEvent(new Event(isFocused ? "focus" : "blur"));
}, focused);
}
interface RenderedTerminalSize {
rows: number;
cols: number;
}
async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSize | null> {
return await page.evaluate(() => {
const terminal = (window as Window & { __paseoTerminal?: { rows: number; cols: number } })
.__paseoTerminal;
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
});
}
async function createTerminalViaMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-new-tab-menu-trigger").click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
const listed = await harness.client.listTerminals(harness.tempRepo.path, undefined, {
workspaceId: harness.workspaceId,
});
return listed.terminals.map((terminal) => terminal.id);
}
// These narrow instead of asserting: an `expect(...).toBeTruthy()` plus an early return would let
// the test pass without ever reaching the assertions that prove the fix.
function exactlyOne<T>(values: T[], what: string): T {
const [value] = values;
if (values.length !== 1 || value === undefined) {
throw new Error(`Expected exactly one ${what}, got ${values.length}`);
}
return value;
}
function requireTerminalSize(size: RenderedTerminalSize | null): RenderedTerminalSize {
if (!size) {
throw new Error("No xterm instance rendered on the page");
}
return size;
}
function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null {
const matches = [...bufferText.matchAll(/S\d+=(\d+) (\d+)=/g)];
const match = matches.at(-1);
return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null;
}
test.describe("terminal PTY size claim under lost window focus", () => {
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-stuck-size" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test("a terminal created while the window is blurred claims its size when focus returns", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({
timeout: 30_000,
});
// Wait for the daemon to know about the first terminal rather than sleeping: if it were
// missing from this snapshot it would be misread as "new" below.
await createTerminalViaMenu(page);
await expect(harness.terminalSurface(page).first()).toBeVisible({ timeout: 30_000 });
const countTerminals = async () => (await listTerminalIds(harness)).length;
await expect.poll(countTerminals, { timeout: 15_000 }).toBe(1);
const knownIds = new Set(await listTerminalIds(harness));
// The user clicks away...
await setWindowFocused(page, false);
// ...opens another terminal while the window is unfocused...
await createTerminalViaMenu(page);
const findNewTerminalIds = async () =>
(await listTerminalIds(harness)).filter((id) => !knownIds.has(id));
await expect.poll(async () => (await findNewTerminalIds()).length, { timeout: 15_000 }).toBe(1);
const newTerminalId = exactlyOne(await findNewTerminalIds(), "new terminal");
// Keep the app blurred through the complete mount-time refit ladder, which runs out to 2s.
await page.waitForTimeout(2_500);
await harness.client.subscribeTerminal(newTerminalId);
// Confirm the PTY is still at its spawn size before focus returns.
harness.client.sendTerminalInput(newTerminalId, {
type: "input",
data: 'echo "S0=$(stty size)="\n',
});
await expect
.poll(async () => getTerminalBufferText(page), { timeout: 15_000 })
.toMatch(/S0=24 80=/);
// ...and comes back.
await setWindowFocused(page, true);
// __paseoTerminal points at the most recently mounted xterm — the new terminal.
const rendered = requireTerminalSize(await readRenderedTerminalSize(page));
// Sanity: the pane really rendered at a desktop size, not the PTY default.
expect(rendered.cols).toBeGreaterThan(80);
// The PTY itself must agree. Ask it via the daemon, never via the page: focusing or
// typing in the pane triggers the focus-claim path and would mask the bug.
let probe = 1;
await expect
.poll(
async () => {
harness.client.sendTerminalInput(newTerminalId, {
type: "input",
data: `echo "S${probe++}=$(stty size)="\n`,
});
return parseLatestSttySize(await getTerminalBufferText(page));
},
{ timeout: 15_000, intervals: [100, 250, 500] },
)
.toEqual({ rows: rendered.rows, cols: rendered.cols });
});
});
@@ -0,0 +1,188 @@
import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "./fixtures";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import {
observeLastAssistantFrames,
observeTimelineSubscriptions,
} from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "./helpers/workspace-ui";
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
}
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (title: string) =>
workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat"),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
await waitForWorkspaceTabsVisible(page);
}
async function selectAgent(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function enableMoveTabShortcut(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
}
async function moveActiveTabRight(page: Page) {
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
}
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
await scenario.client.sendAgentMessage(agentId, prompt);
const finish = await scenario.client.waitForFinish(agentId, 30_000);
expect(finish.status).toBe("idle");
}
test.describe("Viewed agent timelines", () => {
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
page,
}) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
await scenario.client.sendAgentMessage(
scenario.firstAgentId,
"Stream while focused, then finish while hidden.",
);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
expect(finish.status).toBe("idle");
const assistantFrames = await observeLastAssistantFrames(page);
await selectAgent(page, "First viewed chat");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
const snapshots = await assistantFrames.stop();
expect(snapshots[0]).toContain("(end of synthetic stream)");
} finally {
await scenario.cleanup();
}
});
test("a hidden retained chat catches up when shown", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await commitMessage(
scenario,
scenario.firstAgentId,
"Committed while the first chat is hidden.",
);
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toHaveCount(0);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("two visible split chats both stay current", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await enableMoveTabShortcut(page);
await openAgent(page, scenario, scenario.firstAgentId);
await page.getByRole("button", { name: "Split pane right" }).click();
await selectAgent(page, "Second viewed chat");
await moveActiveTabRight(page);
await expect(
page.getByRole("button", { name: "First viewed chat", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("a visible chat catches up after reconnecting", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
"aria-selected",
"true",
);
await gate.drop();
await expectReconnectingToastVisible(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
gate.restore();
await expectReconnectingToastGone(page);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
});
await expect(recoveredMessage).toHaveCount(1);
await expect(recoveredMessage).toBeVisible();
} finally {
gate.restore();
await scenario.cleanup();
}
});
});
@@ -0,0 +1,51 @@
import { expect, test } from "./fixtures";
const modifier = process.platform === "darwin" ? "Meta" : "Control";
async function pressFocusModeShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Shift+F`);
}
async function pressSettingsShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Comma`);
}
async function blurActiveElement(page: import("@playwright/test").Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
}
test("focus mode only applies to the active workspace screen", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "focus-mode-boundary-" });
await workspace.navigateTo();
const exitFocusMode = page.getByRole("button", { name: "Exit focus mode" });
const settingsButton = page.getByRole("button", { name: "Settings", exact: true });
const settingsSidebar = page.getByRole("navigation", { name: "Settings" });
await expect(settingsButton).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await blurActiveElement(page);
await pressFocusModeShortcut(page);
await expect(exitFocusMode).toBeVisible();
await expect(settingsButton).toHaveCount(0);
const workspaceUrl = page.url();
await pressSettingsShortcut(page);
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await page.reload();
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await pressFocusModeShortcut(page);
await page.goto(workspaceUrl);
await expect(exitFocusMode).toBeVisible();
await exitFocusMode.click();
await expect(exitFocusMode).toHaveCount(0);
await expect(settingsButton).toBeVisible();
});
@@ -20,7 +20,7 @@ import {
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001";
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
interface RestartDaemonClient {
@@ -1,5 +1,4 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -34,6 +33,7 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -114,61 +114,6 @@ async function expectWorkspaceLocation(
});
}
async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
.catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
};
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
import { restartTestDaemon } from "./helpers/daemon-restart";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
@@ -11,11 +12,12 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Worktree restore after daemon restart", () => {
const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
let daemon: IsolatedHostDaemon;
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
@@ -25,8 +27,9 @@ test.describe("Worktree restore after daemon restart", () => {
test.describe.configure({ retries: 0, timeout: 180_000 });
test.beforeEach(async () => {
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
daemon = await startIsolatedHostDaemon(serverId);
client = await connectSeedClient({ port: daemon.port });
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
tempRepo = await createTempGitRepo("wt-restart-");
});
@@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => {
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
await daemon?.close().catch(() => undefined);
});
async function seedBrowser(page: Page) {
const nowIso = new Date().toISOString();
await page.addInitScript(
({ host, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{
host: buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "restart daemon",
nowIso,
}),
preferences: buildCreateAgentPreferences(serverId),
},
);
}
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
page,
}) => {
const serverId = getServerId();
// A paseo worktree is cut on its own branch named after the slug, and the
// worktree workspace is displayed under the same name. These are the values
// the History table cells must show after restore — never "main".
@@ -76,14 +99,16 @@ test.describe("Worktree restore after daemon restart", () => {
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
// all workspace/agent links from persisted state. Then reconnect both clients.
// Restart this spec's daemon on the same home and port so it rebuilds all
// workspace/agent links from persisted state without replacing the shared
// Playwright daemon owned by global setup.
await client.close().catch(() => undefined);
await worktreeClient.close().catch(() => undefined);
await restartTestDaemon();
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
await daemon.restart();
client = await connectSeedClient({ port: daemon.port });
worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port });
await seedBrowser(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
+1 -1
View File
@@ -249,7 +249,7 @@ test.describe("Worktree restore", () => {
try {
await page.getByTestId("workspace-recovery-action").click();
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
"The project directory needed to restore this worktree no longer exists.",
"The source repository needed to restore this worktree no longer exists.",
);
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
+1
View File
@@ -25,6 +25,7 @@
"extends": "production",
"distribution": "internal",
"android": {
"resourceClass": "large",
"buildType": "apk",
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.109",
"version": "0.2.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
+4 -1
View File
@@ -27,7 +27,10 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
testIgnore:
process.env.E2E_DESKTOP_RUNTIME === "1"
? ["**/*.real.spec.ts"]
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
@@ -16,6 +16,7 @@ import {
type AddProjectHost,
} from "./model";
import {
addProjectMethodEmptyText,
buildAddProjectMethods,
buildCloneLocationOptions,
buildManualGithubRepositoryChoices,
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
});
describe("Add Project options", () => {
it("hides every mutating method when the host lacks stable project identity", () => {
const outdatedHost = { ...HOST, canAddProject: false };
expect(buildAddProjectMethods(outdatedHost)).toEqual([]);
expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project.");
});
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
expect(
buildAddProjectMethods({
+12 -7
View File
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
}
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
if (!host.canAddProject) return [];
const options: AddProjectMethodOption[] = [];
if (host.canAddProject) {
options.push({
id: "directory-search",
label: "Search for directory",
description: `Find a directory on ${host.label}`,
});
}
options.push({
id: "directory-search",
label: "Search for directory",
description: `Find a directory on ${host.label}`,
});
if (host.canBrowse) {
options.push({
id: "browse",
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
return options;
}
export function addProjectMethodEmptyText(host: AddProjectHost | null): string {
return host?.canAddProject === false
? "Update the host to use Add Project."
: "No matching options";
}
function githubMethodDescription(host: AddProjectHost): string {
if (!host.canCloneGithubRepositories) {
return "Update this host to clone GitHub repositories";
+3 -3
View File
@@ -1234,7 +1234,9 @@ function PermissionActionButton({
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
const optionTextStyle = isPrimary
? [permissionStyles.optionText, permissionStyles.optionTextPrimary]
: permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
@@ -1622,8 +1624,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
},
}));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
+24 -10
View File
@@ -21,7 +21,8 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center";
import { CommandCenterProvider } from "@/command-center/provider";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
@@ -40,6 +41,7 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import {
getIsElectronRuntime,
getIsElectronRuntimeMac,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
@@ -108,7 +110,11 @@ import {
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import {
buildOpenProjectRoute,
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
@@ -425,7 +431,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
@@ -442,6 +447,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
@@ -470,7 +477,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isMobile: isCompactLayout,
toggleAgentList,
toggleBothSidebars: toggleDesktopSidebars,
toggleFocusMode,
cycleTheme,
});
@@ -479,11 +485,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth,
viewportWidth,
});
const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled;
const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible =
!isCompactLayout &&
desktopSidebarMounted &&
@@ -497,7 +503,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible,
hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
});
const sidebarChrome = (
<SidebarChrome
@@ -548,6 +554,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<RosettaCalloutSource />
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -565,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return content;
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({
@@ -659,16 +666,23 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
const pathname = usePathname();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const liftTrafficLights =
getIsElectronRuntimeMac() &&
isFocusModeEnabled &&
parseHostWorkspaceRouteFromPathname(pathname) !== null;
useEffect(() => {
if (!enabled || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: liftTrafficLights ? -5 : 0.5,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
}, [enabled, surface0, foreground, liftTrafficLights]);
return null;
}
@@ -987,7 +1001,7 @@ const layoutStyles = StyleSheet.create((theme) => ({
},
windowSidebarToggle: {
position: "absolute",
top: 0,
top: 1,
left: 0,
zIndex: 20,
height: HEADER_INNER_HEIGHT,
+4 -8
View File
@@ -257,10 +257,10 @@ export default function PairScanScreen() {
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.scanFrame}>
<View style={CORNER_TL_STYLE} />
<View style={CORNER_TR_STYLE} />
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
</View>
@@ -272,7 +272,3 @@ export default function PairScanScreen() {
}
const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] };
const CORNER_TL_STYLE = [styles.corner, styles.cornerTL];
const CORNER_TR_STYLE = [styles.corner, styles.cornerTR];
const CORNER_BL_STYLE = [styles.corner, styles.cornerBL];
const CORNER_BR_STYLE = [styles.corner, styles.cornerBR];
@@ -12,6 +12,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { getForgePresentation } from "@/git/forge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface AttachmentPillContent {
@@ -27,10 +28,16 @@ function getReviewSubtitle(count: number, t: TFunction): string {
}
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
if (
attachment.kind === "forge.change_request_check" ||
attachment.kind === "github.pull_request_check"
) {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
if (
attachment.kind === "forge.change_request_comment" ||
attachment.kind === "github.pull_request_comment"
) {
return "Comment";
}
return "Review";
@@ -57,12 +64,21 @@ export function getAgentAttachmentPillContent(
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.comments.length, t),
};
case "forge_change_request": {
const presentation = getForgePresentation(attachment.forge ?? "github");
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `${presentation.changeRequestAbbrev} ${presentation.numberPrefix}${attachment.number}`,
};
}
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "forge_issue":
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
+12 -3
View File
@@ -1,6 +1,6 @@
import type {
AgentAttachment,
GitHubSearchItem,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -54,6 +54,9 @@ export interface BrowserElementAttachment {
}
export type PullRequestContextAttachmentKind =
| "forge.change_request_comment"
| "forge.change_request_review"
| "forge.change_request_check"
| "github.pull_request_comment"
| "github.pull_request_review"
| "github.pull_request_check";
@@ -67,6 +70,9 @@ interface PullRequestContextAttachmentFields {
}
export type PullRequestContextAttachment =
| ({ kind: "forge.change_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_check" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
@@ -89,10 +95,13 @@ export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
| { kind: "github_issue"; item: ForgeSearchItem }
| {
kind: "github_pr";
item: GitHubSearchItem;
item: ForgeSearchItem;
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
};
@@ -10,6 +10,9 @@ export function isPullRequestContextAttachment(
attachment: ComposerAttachment | undefined,
): attachment is PullRequestContextAttachment {
return (
attachment?.kind === "forge.change_request_comment" ||
attachment?.kind === "forge.change_request_review" ||
attachment?.kind === "forge.change_request_check" ||
attachment?.kind === "github.pull_request_comment" ||
attachment?.kind === "github.pull_request_review" ||
attachment?.kind === "github.pull_request_check"
@@ -81,12 +81,15 @@ function areWorkspaceAttachmentsEqual(
}
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
const isContextAttachment =
attachment.kind === "chat_history" ||
attachment.kind === "github.pull_request_comment" ||
attachment.kind === "github.pull_request_review" ||
attachment.kind === "github.pull_request_check";
if (!isContextAttachment) {
if (
attachment.kind !== "chat_history" &&
attachment.kind !== "forge.change_request_comment" &&
attachment.kind !== "forge.change_request_review" &&
attachment.kind !== "forge.change_request_check" &&
attachment.kind !== "github.pull_request_comment" &&
attachment.kind !== "github.pull_request_review" &&
attachment.kind !== "github.pull_request_check"
) {
return null;
}
return JSON.stringify({
@@ -0,0 +1,792 @@
import {
FlatList,
Modal,
Pressable,
Text,
TextInput,
View,
type ListRenderItemInfo,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetTextInput,
type BottomSheetFlatListMethods,
} from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useProjects } from "@/hooks/use-projects";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
moveActiveResultId,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterWorkspaceResult,
} from "./results";
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedSettings = withUnistyles(Settings, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
}
function SettingsIcon({ size }: CommandCenterIconProps) {
return <ThemedSettings size={size} strokeWidth={2.2} />;
}
function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const platform = {
isMac: getShortcutOs() === "mac",
isDesktop: getIsElectronRuntime(),
};
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
}
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
const actions = useMemo<CommandCenterContribution[]>(
() => [
{
id: "new-agent",
group: "actions",
groupRank: 0,
rank: 0,
keywords: ["open", "project", "folder", "workspace", "repo"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
openAddProject();
},
presentation: {
kind: "action",
title: t("shell.commandCenter.addProject"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PlusIcon,
shortcutKeys: resolveActionShortcutKeys("new-agent", overrides),
},
},
{
id: "home",
group: "actions",
groupRank: 0,
rank: 1,
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(homeRoute);
},
presentation: {
kind: "action",
title: t("shell.commandCenter.home"),
sectionTitle: t("shell.commandCenter.actions"),
icon: HomeIcon,
},
},
{
id: "settings",
group: "actions",
groupRank: 0,
rank: 2,
keywords: ["settings", "preferences", "config", "configuration"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(settingsRoute);
},
presentation: {
kind: "action",
title: t("sidebar.actions.settings"),
sectionTitle: t("shell.commandCenter.actions"),
icon: SettingsIcon,
},
},
],
[homeRoute, openAddProject, overrides, settingsRoute, t],
);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
const leftAttention = left.requiresAttention ? 1 : 0;
const rightAttention = right.requiresAttention ? 1 : 0;
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
const leftRunning = left.status === "running" ? 1 : 0;
const rightRunning = right.status === "running" ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const showAgentHost = useHosts().length > 1;
return useMemo(() => {
if (!open) return [];
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
const title = workspace.title ?? workspace.name;
const subtitle = workspace.currentBranch
? `${host.serverName} · ${workspace.currentBranch}`
: host.serverName;
const searchText = `${title} ${subtitle}`.toLowerCase();
allWorkspaces.push({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title,
subtitle,
searchText,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
},
});
}
}
}
allWorkspaces.sort((left, right) => {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
});
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const workspaces = allWorkspaces.filter((workspace) =>
matchesQuery(workspace.searchText, query),
);
const agentResults = agents
.map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = [
showAgentHost ? agent.serverLabel : null,
location,
formatTimeAgo(agent.lastActivityAt),
]
.filter((part): part is string => Boolean(part))
.join(" · ");
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title,
subtitle,
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent));
return [
{
id: "workspaces",
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: workspaces,
},
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
];
}, [agents, open, projects, query, showAgentHost, t]);
}
interface CommandCenterState {
open: boolean;
query: string;
setQuery(query: string): void;
activeId: string | null;
rows: readonly CommandCenterListRow[];
results: readonly CommandCenterResult[];
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>;
close(): void;
select(result: CommandCenterResult): void;
key(key: string): boolean;
}
function useCommandCenterState(): CommandCenterState {
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const snapshot = useCommandCenterContributions();
const inputRef = useRef<TextInput>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
[query, snapshot.contributions],
);
const projection = useMemo(
() => projectCommandCenterRows([...contributionSections, ...builtInSections]),
[builtInSections, contributionSections],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
(result: CommandCenterResult) => {
setOpen(false);
void result.run();
},
[setOpen],
);
const key = useCallback(
(pressed: string): boolean => {
if (!open) return false;
const results = projection.selectableResults;
if (pressed === "Escape") {
close();
return true;
}
if (pressed === "Enter") {
const selected = results.find((result) => result.id === resolvedActiveId);
if (!selected) return false;
select(selected);
return true;
}
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
if (results.length === 0) return false;
const direction = pressed === "ArrowDown" ? "next" : "previous";
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
return true;
},
[close, open, projection.selectableResults, resolvedActiveId, select],
);
useEffect(() => {
const wasOpen = previousOpenRef.current;
previousOpenRef.current = open;
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
setQuery("");
setActiveId(null);
if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement();
if (!element) return;
const cancel = focusWithRetries({
focus: () => element.focus(),
isFocused: () => typeof document !== "undefined" && document.activeElement === element,
onTimeout: () =>
keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }),
});
return cancel;
}, [open]);
useEffect(() => {
if (!open || !isWeb) return;
const listener = (event: KeyboardEvent) => {
if (key(event.key)) event.preventDefault();
};
window.addEventListener("keydown", listener, true);
return () => window.removeEventListener("keydown", listener, true);
}, [key, open]);
return {
open,
query,
setQuery,
activeId: resolvedActiveId,
rows: projection.rows,
results: projection.selectableResults,
rowIndexByResultId: projection.rowIndexByResultId,
offsets: projection.offsets,
inputRef,
close,
select,
key,
};
}
interface ResultRowProps {
result: CommandCenterResult;
active: boolean;
onSelect(result: CommandCenterResult): void;
}
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
const press = useCallback(() => onSelect(result), [onSelect, result]);
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(result.kind === "agent" ||
result.kind === "workspace" ||
(result.kind === "contribution" &&
result.contribution.presentation.kind === "action" &&
Boolean(result.contribution.presentation.subtitle))) &&
styles.tallRow,
(Boolean(hovered) || pressed || active) && styles.activeRow,
],
[active, result],
);
return (
<Pressable style={style} onPress={press}>
<ResultContent result={result} />
</Pressable>
);
});
function ResultContent({ result }: { result: CommandCenterResult }) {
if (result.kind === "agent") {
const agent = result.agent;
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
{result.subtitle}
</Text>
</View>
</View>
</View>
);
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{result.subtitle}
</Text>
</View>
</View>
</View>
);
}
const presentation = result.contribution.presentation;
const Icon = presentation.icon;
if (presentation.kind === "action") {
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{Icon ? (
<View style={styles.iconSlot}>
<Icon size={16} />
</View>
) : null}
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{presentation.title}
</Text>
{presentation.subtitle ? (
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
) : null}
</View>
</View>
{presentation.shortcutKeys ? (
<Shortcut chord={presentation.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
);
}
return (
<View style={styles.rowContent} testID={presentation.testId}>
<View style={styles.rowMain}>
{Icon ? (
<View style={styles.iconSlot}>
<Icon size={16} />
</View>
) : null}
<View style={styles.breadcrumb}>
{presentation.path.map((part, index) => (
<View
key={presentation.path.slice(0, index + 1).join("\u0000")}
style={styles.breadcrumbPart}
>
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
<Text
style={
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
}
numberOfLines={1}
>
{part}
</Text>
</View>
))}
</View>
</View>
{presentation.selected ? (
<View style={styles.iconSlot}>
<ThemedCheck size={16} strokeWidth={2.2} />
</View>
) : null}
</View>
);
}
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
let sizeStyle = styles.dividerSection;
if (row.title && row.divider) sizeStyle = styles.dividedSection;
if (row.title && !row.divider) sizeStyle = styles.titledSection;
return (
<View style={sizeStyle}>
{row.divider ? <View style={styles.sectionDivider} /> : null}
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
</View>
);
}
export function CommandCenter() {
const { t } = useTranslation();
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
onClose: state.close,
});
useEffect(() => {
if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId);
if (index === undefined) return;
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
useEffect(() => {
if (!showBottomSheet || !state.open) return;
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(timer);
}, [showBottomSheet, state.open]);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<CommandCenterListRow>) =>
item.kind === "section" ? (
<SectionRow row={item} />
) : (
<ResultRow
result={item.result}
active={item.result.id === state.activeId}
onSelect={state.select}
/>
),
[state.activeId, state.select],
);
const getItemLayout = useCallback(
(_data: ArrayLike<CommandCenterListRow> | null | undefined, index: number) => ({
index,
length: state.rows[index].height,
offset: state.offsets[index],
}),
[state.offsets, state.rows],
);
const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
const empty = useMemo(
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
[t],
);
const commonListProps = {
data: state.rows,
renderItem,
keyExtractor,
getItemLayout,
ListEmptyComponent: empty,
keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
showsVerticalScrollIndicator: false,
initialNumToRender: 12,
maxToRenderPerBatch: 10,
windowSize: 5,
};
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
const backdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={backdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.sheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
value={state.query}
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
backgroundColor: theme.colors.surface0,
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
input: {
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[1],
color: theme.colors.foreground,
outlineWidth: 0,
},
results: { flexGrow: 0 },
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.border,
},
row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
tallRow: { height: 56 },
activeRow: { backgroundColor: theme.colors.surface1 },
rowContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
textContent: { flex: 1, minWidth: 0 },
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
flexShrink: 1,
},
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
rowShortcut: { flexShrink: 0 },
breadcrumb: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
breadcrumbPart: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 1,
},
breadcrumbGroup: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 20,
flexShrink: 0,
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
sheetBackground: { backgroundColor: theme.colors.surface0 },
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
titledSection: { height: 32, justifyContent: "flex-end" },
dividedSection: { height: 49, justifyContent: "flex-end" },
dividerSection: { height: 17, justifyContent: "flex-end" },
}));
@@ -0,0 +1,53 @@
import type { ComponentType } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
export interface CommandCenterIconProps {
size: number;
}
export type CommandCenterIcon = ComponentType<CommandCenterIconProps>;
interface CommandCenterContributionBase {
id: string;
group: string;
groupRank: number;
rank: number;
keywords: readonly string[];
visibility: "always" | "query";
run(): void | Promise<void>;
}
export type CommandCenterContribution =
| (CommandCenterContributionBase & {
presentation: {
kind: "action";
title: string;
subtitle?: string;
sectionTitle?: string;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
};
})
| (CommandCenterContributionBase & {
presentation: {
kind: "choice";
path: readonly [string, ...string[]];
icon?: CommandCenterIcon;
selected: boolean;
testId?: string;
};
});
export interface CommandCenterContributionSnapshot {
contributions: readonly CommandCenterContribution[];
}
export interface CommandCenterRegistrationOwner {
sourceId: string;
token: symbol;
}
export interface CommandCenterRegistration {
owner: CommandCenterRegistrationOwner;
contributions: readonly CommandCenterContribution[];
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { buildModelChoiceContributions } from "./model-contributions";
function TestIcon() {
return null;
}
function provider(input: {
id: string;
label: string;
state: "models" | "error";
}): ProviderSelectorProvider {
if (input.state === "error") {
return {
id: input.id,
label: input.label,
modelSelection: { kind: "error", message: "Unavailable" },
};
}
return {
id: input.id,
label: input.label,
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: `${input.id}:model`,
provider: input.id,
providerLabel: input.label,
modelId: "model",
modelLabel: `${input.label} model`,
description: undefined,
},
],
},
};
}
describe("Command Center model choices", () => {
it("publishes selectable providers and executes the draft owner's command", () => {
const selections: string[] = [];
const choices = buildModelChoiceContributions({
serverId: "host",
providers: [
provider({ id: "claude", label: "Claude", state: "models" }),
provider({ id: "codex", label: "Codex", state: "models" }),
],
selectedProvider: "codex",
selectedModelId: "model",
groupLabel: "Model",
searchKeywords: "model switch",
getIcon: () => TestIcon,
select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`),
});
expect(
choices.map((choice) => ({
id: choice.id,
selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false,
})),
).toEqual([
{ id: "host:claude:model", selected: false },
{ id: "host:codex:model", selected: true },
]);
choices[0].run();
choices[1].run();
expect(selections).toEqual(["claude:model"]);
});
it("does not publish unavailable providers or no-op default rows", () => {
const choices = buildModelChoiceContributions({
serverId: "host",
providers: [
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
{
id: "empty",
label: "Empty",
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "empty:",
provider: "empty",
providerLabel: "Empty",
modelId: "",
modelLabel: "Default",
description: undefined,
},
],
},
},
],
selectedProvider: null,
selectedModelId: null,
groupLabel: "Model",
searchKeywords: "model switch",
getIcon: () => TestIcon,
select: () => undefined,
});
expect(choices).toEqual([]);
});
});
@@ -0,0 +1,51 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
interface ModelChoiceInput {
serverId: string;
providers: readonly ProviderSelectorProvider[];
selectedProvider: string | null;
selectedModelId: string | null;
groupLabel: string;
searchKeywords: string;
getIcon(provider: AgentProvider): CommandCenterIcon;
select(provider: AgentProvider, modelId: string): void;
}
export function buildModelChoiceContributions(
input: ModelChoiceInput,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
let rank = 0;
for (const provider of input.providers) {
if (provider.modelSelection.kind !== "models") continue;
const agentProvider = provider.id;
const icon = input.getIcon(agentProvider);
for (const model of provider.modelSelection.rows) {
if (!model.modelId) continue;
const modelId = model.modelId;
const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId;
contributions.push({
id: `${input.serverId}:${provider.id}:${modelId}`,
group: "models",
groupRank: 1,
rank,
keywords: [modelId, input.searchKeywords],
visibility: "query",
run: () => {
if (!selected) input.select(agentProvider, modelId);
},
presentation: {
kind: "choice",
path: [input.groupLabel, provider.label, model.modelLabel],
icon,
selected,
testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`,
},
});
rank += 1;
}
}
return contributions;
}
@@ -0,0 +1,20 @@
import { withUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { getProviderIcon } from "@/components/provider-icons";
import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
const commandCenterProviderIcons = new Map<AgentProvider, CommandCenterIcon>();
export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon {
const cached = commandCenterProviderIcons.get(provider);
if (cached) return cached;
const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({
color: theme.colors.foregroundMuted,
}));
function CommandCenterProviderIcon({ size }: CommandCenterIconProps) {
return <ProviderIcon size={size} />;
}
commandCenterProviderIcons.set(provider, CommandCenterProviderIcon);
return CommandCenterProviderIcon;
}
@@ -0,0 +1,56 @@
import {
createContext,
useContext,
useEffect,
useRef,
useSyncExternalStore,
type ReactNode,
} from "react";
import type { CommandCenterContribution } from "./contributions";
import { createCommandCenterRegistry, type CommandCenterRegistry } from "./registry";
const CommandCenterRegistryContext = createContext<CommandCenterRegistry | null>(null);
export function CommandCenterProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<CommandCenterRegistry | null>(null);
if (!registryRef.current) registryRef.current = createCommandCenterRegistry();
return (
<CommandCenterRegistryContext.Provider value={registryRef.current}>
{children}
</CommandCenterRegistryContext.Provider>
);
}
function useCommandCenterRegistry(): CommandCenterRegistry {
const registry = useContext(CommandCenterRegistryContext);
if (!registry) throw new Error("CommandCenterProvider is required");
return registry;
}
export function useCommandCenterContributions() {
const registry = useCommandCenterRegistry();
return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
}
export function useCommandCenterActions(input: {
sourceId: string;
enabled: boolean;
actions: readonly CommandCenterContribution[];
}): void {
const registry = useCommandCenterRegistry();
const ownerRef = useRef({ sourceId: input.sourceId, token: Symbol(input.sourceId) });
if (ownerRef.current.sourceId !== input.sourceId) {
ownerRef.current = { sourceId: input.sourceId, token: Symbol(input.sourceId) };
}
const owner = ownerRef.current;
useEffect(() => {
if (!input.enabled) {
registry.remove(owner);
return;
}
registry.replace({ owner, contributions: input.actions });
return () => registry.remove(owner);
}, [input.actions, input.enabled, owner, registry]);
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution, CommandCenterRegistrationOwner } from "./contributions";
import { createCommandCenterRegistry } from "./registry";
function action(id: string, rank: number): CommandCenterContribution {
return {
id,
group: "actions",
groupRank: 0,
rank,
keywords: [],
visibility: "always",
run: () => undefined,
presentation: { kind: "action", title: id },
};
}
function owner(sourceId: string): CommandCenterRegistrationOwner {
return { sourceId, token: Symbol(sourceId) };
}
describe("Command Center registry", () => {
it("atomically replaces a source and preserves a no-op snapshot", () => {
const registry = createCommandCenterRegistry();
const source = owner("root");
const first = [action("first", 0)];
let notifications = 0;
registry.subscribe(() => {
notifications += 1;
});
registry.replace({ owner: source, contributions: first });
const snapshot = registry.getSnapshot();
registry.replace({ owner: source, contributions: first });
expect(registry.getSnapshot()).toBe(snapshot);
expect(notifications).toBe(1);
registry.replace({ owner: source, contributions: [action("second", 0)] });
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["root:second"]);
expect(notifications).toBe(2);
});
it("does not let stale cleanup remove a replacement owner", () => {
const registry = createCommandCenterRegistry();
const stale = owner("draft:tab");
const current = owner("draft:tab");
registry.replace({ owner: stale, contributions: [action("old", 0)] });
registry.replace({ owner: current, contributions: [action("new", 0)] });
registry.remove(stale);
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["draft:tab:new"]);
registry.remove(current);
expect(registry.getSnapshot().contributions).toEqual([]);
});
it("orders independently of registration order and rejects duplicate active ids", () => {
const registry = createCommandCenterRegistry();
registry.replace({ owner: owner("later"), contributions: [action("z", 2)] });
registry.replace({ owner: owner("earlier"), contributions: [action("a", 1)] });
expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual([
"earlier:a",
"later:z",
]);
const duplicateOwner = owner("duplicate");
expect(() =>
registry.replace({
owner: duplicateOwner,
contributions: [action("same", 0), action("same", 1)],
}),
).toThrow("Duplicate Command Center contribution id: duplicate:same");
});
});
+100
View File
@@ -0,0 +1,100 @@
import type {
CommandCenterContribution,
CommandCenterContributionSnapshot,
CommandCenterRegistration,
CommandCenterRegistrationOwner,
} from "./contributions";
export interface CommandCenterRegistry {
getSnapshot(): CommandCenterContributionSnapshot;
subscribe(listener: () => void): () => void;
replace(registration: CommandCenterRegistration): void;
remove(owner: CommandCenterRegistrationOwner): void;
}
interface ActiveRegistration {
owner: CommandCenterRegistrationOwner;
contributions: readonly CommandCenterContribution[];
}
const EMPTY_SNAPSHOT: CommandCenterContributionSnapshot = { contributions: [] };
function contributionId(sourceId: string, id: string): string {
return `${sourceId}:${id}`;
}
function compareContributions(
left: CommandCenterContribution,
right: CommandCenterContribution,
): number {
if (left.groupRank !== right.groupRank) return left.groupRank - right.groupRank;
const groupDelta = left.group.localeCompare(right.group);
if (groupDelta !== 0) return groupDelta;
if (left.rank !== right.rank) return left.rank - right.rank;
return left.id.localeCompare(right.id);
}
function sameContributions(
left: readonly CommandCenterContribution[],
right: readonly CommandCenterContribution[],
): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
export function createCommandCenterRegistry(): CommandCenterRegistry {
const registrations = new Map<string, ActiveRegistration>();
const listeners = new Set<() => void>();
let snapshot = EMPTY_SNAPSHOT;
function publish(): void {
const contributions: CommandCenterContribution[] = [];
const ids = new Set<string>();
for (const registration of registrations.values()) {
for (const contribution of registration.contributions) {
const id = contributionId(registration.owner.sourceId, contribution.id);
if (ids.has(id)) {
throw new Error(`Duplicate Command Center contribution id: ${id}`);
}
ids.add(id);
contributions.push({ ...contribution, id });
}
}
contributions.sort(compareContributions);
if (sameContributions(snapshot.contributions, contributions)) return;
snapshot = contributions.length === 0 ? EMPTY_SNAPSHOT : { contributions };
for (const listener of listeners) listener();
}
return {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
replace(registration) {
const current = registrations.get(registration.owner.sourceId);
if (
current?.owner.token === registration.owner.token &&
sameContributions(current.contributions, registration.contributions)
) {
return;
}
const ids = new Set<string>();
for (const contribution of registration.contributions) {
const id = contributionId(registration.owner.sourceId, contribution.id);
if (ids.has(id)) throw new Error(`Duplicate Command Center contribution id: ${id}`);
ids.add(id);
}
registrations.set(registration.owner.sourceId, registration);
publish();
},
remove(owner) {
const current = registrations.get(owner.sourceId);
if (current?.owner.token !== owner.token) return;
registrations.delete(owner.sourceId);
publish();
},
};
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import {
buildContributionSections,
moveActiveResultId,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterWorkspaceResult,
} from "./results";
function contribution(input: {
id: string;
group: string;
groupRank: number;
visibility?: "always" | "query";
}): CommandCenterContribution {
return {
...input,
rank: 0,
keywords: [input.id],
visibility: input.visibility ?? "always",
run: () => undefined,
presentation: {
kind: "action",
title: input.id,
sectionTitle: input.group,
},
};
}
function workspace(id: string): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id,
title: id,
subtitle: "host",
searchText: id,
run: () => undefined,
};
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = [];
for (const section of sections) {
for (const result of section.results) ids.push(result.id);
}
return ids;
}
describe("Command Center result projection", () => {
it("query-gates model choices and creates one flat row index", () => {
const contributions = [
contribution({ id: "settings", group: "actions", groupRank: 0 }),
contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
];
const emptySections = buildContributionSections(contributions, "");
expect(sectionResultIds(emptySections)).toEqual(["settings"]);
const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([
...sections,
{ id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
]);
expect(projection.rows.map((row) => row.key)).toEqual([
"section:models",
"opus",
"section:workspaces",
"workspace:1",
]);
expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
expect(projection.offsets).toEqual([0, 32, 68, 117]);
});
it("preserves active selection by id and falls back to the first result", () => {
const first = workspace("workspace:first");
const second = workspace("workspace:second");
expect(preserveActiveResultId(second.id, [first, second])).toBe(second.id);
expect(preserveActiveResultId("missing", [first, second])).toBe(first.id);
expect(preserveActiveResultId(first.id, [])).toBeNull();
});
it("wraps keyboard selection in both directions", () => {
const first = workspace("workspace:first");
const second = workspace("workspace:second");
expect(moveActiveResultId(second.id, [first, second], "next")).toBe(first.id);
expect(moveActiveResultId(first.id, [first, second], "previous")).toBe(second.id);
});
it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([
{ id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
]);
let activeId: string | null = workspaces[0].id;
for (let index = 0; index < 150; index += 1) {
activeId = moveActiveResultId(activeId, projection.selectableResults, "next");
}
const targetId = workspaces[150].id;
expect(activeId).toBe(targetId);
expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
expect(projection.offsets[151]).toBe(32 + 150 * 56);
});
});
+181
View File
@@ -0,0 +1,181 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import type { CommandCenterContribution } from "./contributions";
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
export interface CommandCenterAgentResult {
kind: "agent";
id: string;
agent: AggregatedAgent;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
export interface CommandCenterContributionResult {
kind: "contribution";
id: string;
contribution: CommandCenterContribution;
searchText: string;
run(): void | Promise<void>;
}
export type CommandCenterResult =
| CommandCenterWorkspaceResult
| CommandCenterAgentResult
| CommandCenterContributionResult;
export interface CommandCenterResultSection {
id: string;
rank: number;
title?: string;
results: readonly CommandCenterResult[];
}
interface MutableCommandCenterResultSection {
id: string;
rank: number;
title?: string;
results: CommandCenterResult[];
}
export type CommandCenterListRow =
| { kind: "section"; key: string; title?: string; divider: boolean; height: number }
| { kind: "result"; key: string; result: CommandCenterResult; height: number };
export interface CommandCenterListProjection {
rows: readonly CommandCenterListRow[];
selectableResults: readonly CommandCenterResult[];
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
}
function contributionSearchText(contribution: CommandCenterContribution): string {
const presentationText =
contribution.presentation.kind === "action"
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
: contribution.presentation.path;
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
}
function resultHeight(result: CommandCenterResult): number {
if (result.kind === "workspace" || result.kind === "agent") return 56;
if (result.contribution.presentation.kind === "action") {
return result.contribution.presentation.subtitle ? 56 : 36;
}
return 36;
}
export function buildContributionSections(
contributions: readonly CommandCenterContribution[],
query: string,
): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>();
const hasQuery = Boolean(query.trim());
for (const contribution of contributions) {
if (contribution.visibility === "query" && !hasQuery) continue;
const searchText = contributionSearchText(contribution);
if (!matchesQuery(searchText, query)) continue;
const existing = groups.get(contribution.group);
const title =
contribution.presentation.kind === "action"
? contribution.presentation.sectionTitle
: undefined;
const section = existing ?? {
id: contribution.group,
rank: contribution.groupRank,
title,
results: [],
};
section.results.push({
kind: "contribution",
id: contribution.id,
contribution,
searchText,
run: contribution.run,
});
groups.set(contribution.group, section);
}
return [...groups.values()].sort(
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
);
}
export function projectCommandCenterRows(
sections: readonly CommandCenterResultSection[],
): CommandCenterListProjection {
const populated = sections
.filter((section) => section.results.length > 0)
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
const rows: CommandCenterListRow[] = [];
const selectableResults: CommandCenterResult[] = [];
const rowIndexByResultId = new Map<string, number>();
const offsets: number[] = [];
let offset = 0;
for (const [sectionIndex, section] of populated.entries()) {
const divider = sectionIndex > 0;
let sectionHeight = 0;
if (section.title && divider) sectionHeight = 49;
if (section.title && !divider) sectionHeight = 32;
if (!section.title && divider) sectionHeight = 17;
if (sectionHeight > 0) {
offsets.push(offset);
rows.push({
kind: "section",
key: `section:${section.id}`,
title: section.title,
divider,
height: sectionHeight,
});
offset += sectionHeight;
}
for (const result of section.results) {
const height = resultHeight(result);
offsets.push(offset);
rowIndexByResultId.set(result.id, rows.length);
rows.push({ kind: "result", key: result.id, result, height });
selectableResults.push(result);
offset += height;
}
}
return { rows, selectableResults, rowIndexByResultId, offsets };
}
export function preserveActiveResultId(
activeId: string | null,
results: readonly CommandCenterResult[],
): string | null {
if (activeId && results.some((result) => result.id === activeId)) return activeId;
return results[0]?.id ?? null;
}
export function moveActiveResultId(
activeId: string | null,
results: readonly CommandCenterResult[],
direction: "next" | "previous",
): string | null {
if (results.length === 0) return null;
const current = results.findIndex((result) => result.id === activeId);
const delta = direction === "next" ? 1 : -1;
let start = current;
if (current < 0 && direction === "previous") start = 0;
const next = (start + delta + results.length) % results.length;
return results[next].id;
}
@@ -230,7 +230,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -373,7 +372,7 @@ export function SheetHeaderView({
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
style={[styles.searchInput, isWeb && { outlineStyle: "none" }]}
placeholder={search.placeholder ?? t("common.actions.search")}
resetKey={search.resetKey}
onChangeText={handleSearchChange}
@@ -430,7 +429,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
style={[styles.searchInput, isWeb && { outlineStyle: "none" }]}
placeholder={header.search.placeholder ?? t("common.actions.search")}
resetKey={header.search.resetKey}
onChangeText={header.search.onChange}
@@ -459,6 +458,8 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/** Whether gestures may dismiss the compact bottom sheet. */
dismissible?: boolean;
}
export function AdaptiveModalSheet({
@@ -473,6 +474,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
dismissible = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -609,7 +611,7 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
enablePanDownToClose={dismissible}
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
@@ -45,6 +45,7 @@ import {
} from "@/add-project-flow/model";
import {
buildAddProjectMethods,
addProjectMethodEmptyText,
buildCloneLocationOptions,
buildManualGithubRepositoryChoices,
buildSuggestedParentDirectories,
@@ -161,9 +162,10 @@ function progressText(page: AddProjectPage): string {
return "Adding project...";
}
function emptyText(page: AddProjectPage): string {
function emptyText(page: AddProjectPage, host: AddProjectHost | null): string {
if (page.kind === "host") return "No connected hosts";
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
if (page.kind === "method") return addProjectMethodEmptyText(host);
return "No matching options";
}
@@ -297,6 +299,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity");
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
@@ -308,7 +312,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
() =>
hosts.flatMap((host) => {
if (connectionStatuses.get(host.serverId) !== "online") return [];
const canAddProject = projectAddByHost.get(host.serverId) === true;
const canAddProject =
projectAddByHost.get(host.serverId) === true &&
stableProjectIdentityByHost.get(host.serverId) === true;
return [
{
serverId: host.serverId,
@@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
hosts,
localServerId,
projectAddByHost,
stableProjectIdentityByHost,
],
);
const [state, setState] = useState(() =>
@@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
rows.length === 0 &&
page.kind !== "new-directory-name" ? (
<Text style={styles.stateText} testID="add-project-flow-empty">
{emptyText(page)}
{emptyText(page, host ?? null)}
</Text>
) : null}
</ScrollView>
+2 -5
View File
@@ -544,7 +544,7 @@ export function AgentList({
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
style={SHEET_CANCEL_BUTTON_STYLE}
style={[styles.sheetButton, styles.sheetCancelButton]}
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
@@ -552,7 +552,7 @@ export function AgentList({
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
style={SHEET_ARCHIVE_BUTTON_STYLE}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
@@ -789,6 +789,3 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
}));
const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton];
const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton];
@@ -3,11 +3,16 @@ import {
type BrowserWebviewProfileHost,
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
getResidentBrowserWebview,
prepareBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
import {
setCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "../utils/command-center-focus-restore";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
const attachedBrowsers: Array<{
@@ -254,6 +259,20 @@ describe("resident browser webviews", () => {
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
});
it("finds the originating browser webview for focus restoration", () => {
const webview = ensureTestBrowser({
browserId: "browser-focus",
workspaceId: "workspace-focus",
url: "https://example.com",
});
setCommandCenterFocusRestoreElement(getResidentBrowserWebview("browser-focus"));
expect(takeCommandCenterFocusRestoreElement()).toBe(webview);
expect(getResidentBrowserWebview("browser-missing")).toBeNull();
});
it("removes a resident webview when its browser tab closes", () => {
const webview = ensureTestBrowser({
browserId: "browser-closed",
@@ -54,22 +54,20 @@ function registerBrowserWhenAttached(
identity: BrowserWebviewIdentity,
browser: BrowserWebviewProfileHost,
): void {
webview.addEventListener(
"did-attach",
() => {
const webContentsId = webview.getWebContentsId();
void browser
.registerAttachedBrowser({
browserId: identity.browserId,
workspaceId: identity.workspaceId,
webContentsId,
})
.catch((error) => {
console.error("[browser-webview] attached registration failed", error);
});
},
{ once: true },
);
// Reparenting a webview can replace its guest WebContents without replacing
// this DOM element, so every attachment needs a fresh main-process registration.
webview.addEventListener("did-attach", () => {
const webContentsId = webview.getWebContentsId();
void browser
.registerAttachedBrowser({
browserId: identity.browserId,
workspaceId: identity.workspaceId,
webContentsId,
})
.catch((error) => {
console.error("[browser-webview] attached registration failed", error);
});
});
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -225,6 +223,19 @@ export function ensureResidentBrowserWebview(input: {
return webview;
}
export function getResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (resident?.isConnected) {
return resident;
}
const ownerDocument = readDocument();
return ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
}
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
@@ -1,726 +0,0 @@
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Folder, Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import {
useCommandCenter,
type CommandCenterActionItem,
type CommandCenterAgentItem,
type CommandCenterItem,
type CommandCenterWorkspaceItem,
} from "@/hooks/use-command-center";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
} from "@gorhom/bottom-sheet";
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedFolder = withUnistyles(Folder, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
}
const CommandCenterRow = memo(function CommandCenterRow({
active,
children,
onPress,
registerRow,
onLayout,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
],
[active, theme.colors.surface1],
);
return (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children}
</Pressable>
);
});
interface CommandCenterRowContainerProps {
item: CommandCenterItem;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: CommandCenterItem) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode;
}
function CommandCenterRowContainer({
item,
rowIndex,
active,
rowRefs,
onSelect,
onLayout,
children,
}: CommandCenterRowContainerProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const registerRow = useCallback(
(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
},
[rowRefs, rowIndex],
);
return (
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRow>
);
}
interface CommandCenterActionRowProps {
item: CommandCenterActionItem;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
}
function CommandCenterActionRow({
item,
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
let actionIcon: React.ReactNode = null;
if (item.icon === "plus") {
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (item.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (item.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<CommandCenterRowContainer
item={item}
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onSelect={onSelect}
onLayout={onLayout}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{item.title}
</Text>
</View>
</View>
{item.shortcutKeys ? (
<Shortcut chord={item.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowContentProps {
item: CommandCenterAgentItem;
}
function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const agent = item.agent;
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{item.title}
</Text>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{item.subtitle}
</Text>
</View>
</View>
</View>
);
}
interface AgentItemsSectionProps {
agentItems: CommandCenterAgentItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function AgentItemsSection({
agentItems,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => {
const rowIndex = startIndex + index;
const agent = item.agent;
return (
<CommandCenterRowContainer
key={`${agent.serverId}:${agent.id}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent item={item} />
</CommandCenterRowContainer>
);
})}
</>
);
}
interface WorkspaceItemsSectionProps {
workspaceItems: CommandCenterWorkspaceItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function WorkspaceItemsSection({
workspaceItems,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: WorkspaceItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.workspaces")}</Text>
{workspaceItems.map((item, index) => {
const rowIndex = startIndex + index;
return (
<CommandCenterRowContainer
key={`${item.serverId}:${item.workspaceId}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onSelect={onSelect}
onLayout={onRowLayout(rowIndex)}
>
<View
style={styles.rowContent}
testID={`command-center-workspace-${item.serverId}:${item.workspaceId}`}
>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{item.subtitle}
</Text>
</View>
</View>
</View>
</CommandCenterRowContainer>
);
})}
</>
);
}
export function CommandCenter() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null);
const nativeScrollY = useRef(0);
const nativeViewHeight = useRef(0);
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bottomSheetInputRef = useRef<any>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: showBottomSheet,
onClose: handleClose,
});
// Focus the bottom sheet input when the sheet opens on mobile
useEffect(() => {
if (showBottomSheet && open) {
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
}
}, [showBottomSheet, open]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
// Scroll active row into view
useEffect(() => {
if (!open) return;
if (isWeb) {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") return;
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
return;
}
// Native: use onLayout-measured positions
const layout = rowLayouts.current.get(activeIndex);
if (!layout || !resultsRef.current) return;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) {
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
} else if (rowBottom > visibleBottom) {
resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
animated: true,
});
}
}, [activeIndex, open]);
const handleRowLayout = useCallback(
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
rowLayouts.current.set(rowIndex, {
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
},
[],
);
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const workspaceItems = useMemo(() => items.filter((item) => item.kind === "workspace"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
const panelStyle = useMemo(
() => [
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
],
[theme.colors.border, theme.colors.surface0],
);
const headerStyle = useMemo(
() => [styles.header, { borderBottomColor: theme.colors.border }],
[theme.colors.border],
);
const inputStyle = useMemo(
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionLabelStyle = useMemo(
() => [styles.sectionLabel, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionDividerStyle = useMemo(
() => [styles.sectionDivider, { backgroundColor: theme.colors.border }],
[theme.colors.border],
);
const sheetBackgroundStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
const sheetHandleStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
handleKeyEvent(key);
},
[handleKeyEvent],
);
const handleSubmitEditing = useCallback(() => {
handleKeyEvent("Enter");
}, [handleKeyEvent]);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{workspaceItems.length > 0 ? (
<WorkspaceItemsSection
workspaceItems={workspaceItems}
startIndex={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
startIndex={actionItems.length + workspaceItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
);
// Mobile: bottom sheet
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef as unknown as React.Ref<never>}
value={query}
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder={t("shell.commandCenter.placeholder")}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
);
}
if (!open) return null;
// Desktop web: centered overlay panel
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View testID="command-center-panel" style={panelStyle}>
<View style={headerStyle}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder={t("shell.commandCenter.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
results: {
flexGrow: 0,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingTop: 0,
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
},
row: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
rowContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconSlot: {
width: 16,
height: 20,
alignItems: "center",
justifyContent: "center",
},
textContent: {
flex: 1,
minWidth: 0,
gap: 2,
},
rowShortcut: {
marginLeft: theme.spacing[2],
flexShrink: 0,
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
color: theme.colors.foreground,
},
subtitle: {
fontSize: theme.fontSize.xs,
lineHeight: 18,
color: theme.colors.foregroundMuted,
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
fontSize: theme.fontSize.base,
},
}));
@@ -105,7 +105,7 @@ export function DictationControls({
<Pressable
onPress={onRetry}
accessibilityLabel={t("message.dictation.retry")}
style={ACTION_CONFIRM_STYLE}
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
@@ -115,14 +115,14 @@ export function DictationControls({
<Pressable
onPress={onAccept}
accessibilityLabel={t("message.dictation.insert")}
style={ACTION_SECONDARY_STYLE}
style={[styles.actionButton, styles.actionButtonSecondary]}
>
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel={t("message.dictation.insertAndSend")}
style={ACTION_CONFIRM_STYLE}
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
@@ -240,7 +240,7 @@ export function DictationOverlay({
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.insert")}
style={OVERLAY_ACCEPT_BUTTON_STYLE}
style={[overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]}
>
<Pencil
size={theme.iconSize.lg}
@@ -400,7 +400,4 @@ const overlayStyles = StyleSheet.create((theme) => ({
},
}));
const ACTION_CONFIRM_STYLE = [styles.actionButton, styles.actionButtonConfirm];
const ACTION_SECONDARY_STYLE = [styles.actionButton, styles.actionButtonSecondary];
const OVERLAY_ACCEPT_BUTTON_BG = { backgroundColor: "rgba(255, 255, 255, 0.25)" };
const OVERLAY_ACCEPT_BUTTON_STYLE = [overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG];
@@ -1,8 +1,9 @@
export { reorderItemsOnDragEnd } from "./reorder-items";
export type { DragEndInput } from "./reorder-items";
export {
getPointerActivationConstraint,
type PointerActivationConfig,
getDragActivationConstraints,
type DragActivationConfig,
type DragActivationConstraints,
type PointerActivationConstraint,
} from "./pointer-activation";
export {
@@ -1,14 +1,21 @@
import { describe, expect, it } from "vitest";
import { getPointerActivationConstraint } from "./pointer-activation";
import { getDragActivationConstraints } from "./pointer-activation";
const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
const config = { movementDistance: 6, touchHoldDelayMs: 180, touchHoldTolerance: 8 };
describe("getPointerActivationConstraint", () => {
it("uses distance activation for default draggable rows", () => {
expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
describe("getDragActivationConstraints", () => {
it("starts mouse drags after deliberate pointer movement", () => {
expect(getDragActivationConstraints(true, config).mouse).toEqual({ distance: 6 });
});
it("requires a held pointer before activating handle-based drags", () => {
expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
it("requires a short hold before starting touch drags", () => {
expect(getDragActivationConstraints(true, config).touch).toEqual({
delay: 180,
tolerance: 8,
});
});
it("starts ordinary touch rows after deliberate movement", () => {
expect(getDragActivationConstraints(false, config).touch).toEqual({ distance: 6 });
});
});
@@ -2,18 +2,25 @@ export type PointerActivationConstraint =
| { distance: number }
| { delay: number; tolerance: number };
export interface PointerActivationConfig {
defaultDistance: number;
holdDelayMs: number;
holdTolerance: number;
export interface DragActivationConfig {
movementDistance: number;
touchHoldDelayMs: number;
touchHoldTolerance: number;
}
export function getPointerActivationConstraint(
export interface DragActivationConstraints {
mouse: PointerActivationConstraint;
touch: PointerActivationConstraint;
}
export function getDragActivationConstraints(
useDragHandle: boolean,
config: PointerActivationConfig,
): PointerActivationConstraint {
if (useDragHandle) {
return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
}
return { distance: config.defaultDistance };
config: DragActivationConfig,
): DragActivationConstraints {
const movement = { distance: config.movementDistance };
const touch = useDragHandle
? { delay: config.touchHoldDelayMs, tolerance: config.touchHoldTolerance }
: movement;
return { mouse: movement, touch };
}
@@ -4,7 +4,8 @@ import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type Modifier,
useSensor,
useSensors,
@@ -17,7 +18,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -27,10 +28,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
});
const DND_MODIFIERS = [restrictToVerticalAxis];
const POINTER_ACTIVATION_CONFIG = {
defaultDistance: 6,
holdDelayMs: 250,
holdTolerance: 8,
const DRAG_ACTIVATION_CONFIG = {
movementDistance: 6,
touchHoldDelayMs: 180,
touchHoldTolerance: 8,
};
interface SortableItemProps<T> {
@@ -145,14 +146,14 @@ export function DraggableList<T>({
onDragEnd,
onDragBegin,
});
const pointerActivationConstraint = getPointerActivationConstraint(
useDragHandle,
POINTER_ACTIVATION_CONFIG,
);
const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: pointerActivationConstraint,
useSensor(MouseSensor, {
activationConstraint: activationConstraints.mouse,
}),
useSensor(TouchSensor, {
activationConstraint: activationConstraints.touch,
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
@@ -8,7 +8,7 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -33,7 +33,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
@@ -211,10 +211,12 @@ export function ExplorerSidebar({
return (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
<GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
testID="explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
@@ -347,6 +349,7 @@ function ExplorerSidebarContent({
testID="explorer-tab-pr"
>
<PullRequestTabIcon
forge={prPane.forge}
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
@@ -459,14 +462,6 @@ const styles = StyleSheet.create((theme) => ({
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
resizeHandle: {
position: "absolute",
left: -5,
top: 0,
bottom: 0,
width: 10,
zIndex: 10,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -521,6 +516,3 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
}));
const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }];
const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)];
@@ -575,7 +575,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
}
return (
<View style={TREE_PANE_CONTAINER_STYLE}>
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
@@ -1253,5 +1253,3 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
},
}));
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
+4 -4
View File
@@ -27,7 +27,7 @@ import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
interface CodeLineProps {
@@ -386,10 +386,10 @@ export function FilePane({
);
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
// gate itself lives in isFileQueryEnabled.
// covers tab switches; active app visibility covers backgrounding and returning
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const isAppVisible = useAppActivelyVisible();
const query = useQuery({
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
@@ -45,12 +45,14 @@ function MobileMenuIcon({ color }: { color: string }) {
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
@@ -83,7 +85,12 @@ function SidebarMenuToggleButton({
accessibilityState={accessibilityState}
>
{({ hovered, pressed }) => {
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
@@ -121,7 +128,14 @@ export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps =
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />;
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const CODEBERG_ICON_PATH =
"M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z";
export function CodebergIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={CODEBERG_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const FORGEJO_ICON_PATH =
"M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z";
export function ForgejoIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={FORGEJO_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITEA_ICON_PATH =
"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z";
export function GiteaIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITEA_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITLAB_ICON_PATH =
"M23.6004 9.5927l-.0337-.0862L20.3 .9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z";
export function GitLabIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITLAB_ICON_PATH} />;
}
@@ -8,7 +8,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);
}
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
export interface SvgPathIconProps {
size?: number;
color?: string;
}
interface SvgPathIconInput extends SvgPathIconProps {
path: string;
viewBox: string;
}
export function SvgPathIcon({
size = 16,
color = "currentColor",
path,
viewBox,
}: SvgPathIconInput) {
return (
<Svg width={size} height={size} viewBox={viewBox} fill={color}>
<Path d={path} />
</Svg>
);
}
@@ -9,6 +9,7 @@ import {
getPromptPreview,
getSessionTitle,
resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
type SessionsQueryResult,
sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model";
@@ -70,6 +71,35 @@ describe("resolveProvidersToFetch", () => {
});
});
describe("requiresImportSessionsHostUpgrade", () => {
it("allows home imports on hosts without workspace targeting", () => {
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: null,
supportsWorkspaceTarget: false,
}),
).toBe(false);
});
it("requires host support for imports opened from a workspace", () => {
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: "ws-current",
supportsWorkspaceTarget: false,
}),
).toBe(true);
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: "ws-current",
supportsWorkspaceTarget: true,
}),
).toBe(false);
});
});
describe("buildProviderLabelMap", () => {
it("returns an empty map when snapshot entries are missing", () => {
expect(buildProviderLabelMap(undefined).size).toBe(0);

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