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 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 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 8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
389 changed files with 31523 additions and 8570 deletions
+35
View File
@@ -200,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",
{
+35 -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,21 +37,14 @@ 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.
@@ -46,7 +58,11 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
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`):
@@ -139,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.
+52 -17
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.
+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
@@ -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
+14
View File
@@ -84,6 +84,20 @@ Register the adapter in `defaultForgeRegistry` with:
- `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
+7 -7
View File
@@ -2,22 +2,22 @@
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`).
- **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. 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.
- **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`.
@@ -29,7 +29,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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** — 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.
+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.
+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
+10
View File
@@ -156,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.
@@ -177,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-DL1LamUyFzJOkPYR7eeIefGhzP/mcWGO5oxld/Bt8n0=
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
+74 -45
View File
@@ -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",
@@ -36436,10 +36441,12 @@
"dependencies": {
"@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"
}
@@ -36450,6 +36457,7 @@
"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",
@@ -38051,7 +38080,7 @@
"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.2.0-beta.1",
"@getpaseo/highlight": "0.2.0-beta.1",
@@ -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"
],
@@ -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);
+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);
+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", () => {
+2 -2
View File
@@ -15,7 +15,7 @@ import { getServerId } from "./helpers/server-id";
*
* 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 `useAppVisible` consults on window focus/blur events)
* 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
@@ -23,7 +23,7 @@ import { getServerId } from "./helpers/server-id";
*/
/**
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
* 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
@@ -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();
}
});
});
@@ -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"
}
+1
View File
@@ -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;
+4 -2
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";
@@ -553,6 +554,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<RosettaCalloutSource />
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return content;
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({
+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];
@@ -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];
@@ -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];
@@ -211,7 +211,7 @@ export function ExplorerSidebar({
return (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
@@ -516,5 +516,3 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
}));
const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }];
@@ -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],
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -6,25 +6,91 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"];
function shortcutSearchAliases(keys: string[], shortcutOs: "mac" | "non-mac"): string {
const aliases = keys.map((key) => {
if (shortcutOs === "mac") {
if (key === "mod" || key === "meta") return ["cmd", "command"];
if (key === "alt") return ["alt", "option"];
} else {
if (key === "mod" || key === "ctrl") return ["ctrl", "control"];
if (key === "meta") return ["win", "windows"];
}
return [key];
});
const combinations = aliases.reduce<string[][]>(
(prefixes, choices) =>
prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])),
[[]],
);
return combinations
.flatMap((combination) => [combination.join(" "), combination.join("+")])
.join(" ");
}
export function KeyboardShortcutsDialog() {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const [query, setQuery] = useState("");
const isMac = getShortcutOs() === "mac";
const shortcutOs = getShortcutOs();
const isMac = shortcutOs === "mac";
const isDesktopApp = getIsElectronRuntime();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac],
);
const visibleSections = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return sections;
return sections.flatMap((section) => {
const sectionTitle = t(section.titleKey);
if (sectionTitle.toLocaleLowerCase().includes(normalizedQuery)) {
return [section];
}
const rows = section.rows.filter((row) => {
const searchText = [
t(row.labelKey),
row.noteKey ? t(row.noteKey) : row.note,
row.keys.join(" "),
formatShortcut(row.keys, shortcutOs),
shortcutSearchAliases(row.keys, shortcutOs),
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase();
return searchText.includes(normalizedQuery);
});
return rows.length > 0 ? [{ ...section, rows }] : [];
});
}, [query, sections, shortcutOs, t]);
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const handleClose = useCallback(() => setOpen(false), [setOpen]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.shortcuts.dialogTitle"),
search: {
onChange: setQuery,
resetKey: Number(open),
placeholder: t("settings.shortcuts.searchPlaceholder"),
autoFocus: true,
},
}),
[open, t],
);
return (
<AdaptiveModalSheet
@@ -35,7 +101,7 @@ export function KeyboardShortcutsDialog() {
snapPoints={SNAP_POINTS}
>
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
{visibleSections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
<View style={styles.rows}>
@@ -53,6 +119,9 @@ export function KeyboardShortcutsDialog() {
</View>
</View>
))}
{visibleSections.length === 0 ? (
<Text style={styles.empty}>{t("common.empty.noResults")}</Text>
) : null}
</View>
</AdaptiveModalSheet>
);
@@ -102,4 +171,10 @@ const styles = StyleSheet.create((theme) => ({
rowShortcut: {
alignSelf: "flex-start",
},
empty: {
paddingVertical: theme.spacing[6],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));
@@ -155,7 +155,7 @@ export function ProviderCatalogList({
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
style={[styles.searchInput, isWeb && { outlineStyle: "none" }]}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -284,5 +284,3 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
@@ -225,7 +225,7 @@ function AddCustomModelSubSheet({
autoCorrect={false}
returnKeyType="done"
// @ts-expect-error - outlineStyle is web-only
style={FORM_INPUT_STYLE}
style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]}
/>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
@@ -437,7 +437,9 @@ function renderProviderSheetFooter({
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta;
const metaStyle = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<View style={contentStyle}>
@@ -871,9 +873,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
},
}));
const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta];
const MAIN_SNAP_POINTS = ["65%", "92%"];
const ADD_SNAP_POINTS = ["40%"];
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
@@ -8,6 +8,7 @@ import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -47,7 +48,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
@@ -13,7 +13,7 @@ import { Text, View } from "react-native";
import { Brain, Folder, GitBranch } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ComboboxItem } from "@/components/ui/combobox";
@@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function requireCronCadence(
cadence: Extract<ScheduleCadence, { type: "cron" }> | undefined,
): Extract<ScheduleCadence, { type: "cron" }> {
if (!cadence) {
throw new Error("Choose a cron cadence before creating this schedule");
}
return cadence;
}
function resolveCreateServerId(input: {
mode: "create" | "edit";
serverId: string | null | undefined;
@@ -306,18 +315,15 @@ function OpenScheduleFormSheet({
]);
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
if (!schedule || !state.submitCadence) {
return false;
}
await updateSchedule({
id: schedule.id,
name: state.name.trim() || null,
prompt: state.prompt.trim(),
cadence: state.submitCadence,
maxRuns: parseMaxRuns(state.maxRuns),
});
return true;
}, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]);
}, [schedule, state.submitCadence, updateSchedule]);
const submitNewAgent = useCallback(async (): Promise<boolean> => {
const provider = state.selectedProvider;
@@ -333,7 +339,7 @@ function OpenScheduleFormSheet({
id: schedule.id,
name: state.name.trim() || null,
prompt: state.prompt.trim(),
cadence: state.submitCadence,
...(state.submitCadence ? { cadence: state.submitCadence } : {}),
newAgentConfig: {
provider,
model: state.selectedModel || null,
@@ -353,7 +359,7 @@ function OpenScheduleFormSheet({
await createSchedule({
prompt: state.prompt.trim(),
name: state.name.trim() || undefined,
cadence: state.submitCadence,
cadence: requireCronCadence(state.submitCadence),
target: {
type: "new-agent",
config: {
@@ -394,10 +400,12 @@ function OpenScheduleFormSheet({
void handleSubmit();
}, [handleSubmit]);
const header = useMemo<SheetHeader>(
() => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }),
[mode],
);
const header = useMemo<SheetHeader>(() => {
if (mode !== "edit") {
return { title: "New schedule" };
}
return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" };
}, [mode, schedule?.target.type]);
const footer = useMemo(
() => (
@@ -466,6 +474,21 @@ function ScheduleFormFields({
cadenceError,
mutationServerId,
}: ScheduleFormFieldsProps): ReactElement {
if (state.targetKind === "agent") {
return (
<>
<ScheduleAgentTargetField label={agentTargetLabel} size={controlSize} />
<CadenceEditor
value={state.cadence}
onChange={model.setCadence}
error={cadenceError ?? undefined}
size={controlSize}
/>
{state.submitError ? <Text style={styles.submitError}>{state.submitError}</Text> : null}
</>
);
}
return (
<>
<Field label="Name">
@@ -502,7 +525,7 @@ function ScheduleFormFields({
model={model}
state={state}
providerSnapshot={providerSnapshot}
agentTargetLabel={agentTargetLabel}
agentTargetLabel={null}
controlSize={controlSize}
mutationServerId={mutationServerId}
/>
@@ -16,7 +16,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import {
formatCadence,
formatNextRun,
resolveScheduleTitle,
scheduleProductName,
} from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
@@ -153,9 +158,10 @@ export function ScheduleRow({
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const title = resolveScheduleTitle(schedule);
const productName = scheduleProductName(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -178,7 +184,7 @@ export function ScheduleRow({
style={rowStyle}
onPress={onEdit}
accessibilityRole="button"
accessibilityLabel={`Edit schedule ${title}`}
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
testID={`schedule-row-${schedule.id}`}
>
<View style={styles.main}>
@@ -222,6 +228,66 @@ const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapp
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
function ScheduleExecutionMenuItems({
schedule,
canRun,
pending,
onPause,
onResume,
onRunNow,
}: Pick<ScheduleRowProps, "schedule" | "pending" | "onPause" | "onResume" | "onRunNow"> & {
canRun: boolean;
}): ReactElement | null {
if (schedule.target.type === "agent") {
return null;
}
let cadenceAction: ReactElement;
if (schedule.status === "paused") {
cadenceAction = (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
);
} else {
cadenceAction = (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
);
}
return (
<>
{cadenceAction}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
</DropdownMenuItem>
</>
);
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
return (
<ThemedKebab
@@ -246,13 +312,15 @@ function ScheduleKebabMenu({
> & {
canRun: boolean;
}): ReactElement {
const productName = scheduleProductName(schedule);
const productNameLower = productName.toLowerCase();
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityRole={isNative ? "button" : undefined}
accessibilityLabel="Schedule actions"
accessibilityLabel={`${productName} actions`}
testID={`schedule-kebab-${schedule.id}`}
>
{renderKebabTriggerIcon}
@@ -263,41 +331,16 @@ function ScheduleKebabMenu({
onSelect={onEdit}
testID={`schedule-menu-edit-${schedule.id}`}
>
Edit schedule
</DropdownMenuItem>
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
Edit {productNameLower}
</DropdownMenuItem>
<ScheduleExecutionMenuItems
schedule={schedule}
canRun={canRun}
pending={pending}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={deleteLeading}
@@ -307,7 +350,7 @@ function ScheduleKebabMenu({
onSelect={onDelete}
testID={`schedule-menu-delete-${schedule.id}`}
>
Delete schedule
Delete {productNameLower}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -7,7 +7,7 @@ import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
import { resolveScheduleTitle, scheduleProductName } from "@/utils/schedule-format";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
@@ -113,8 +113,9 @@ function SchedulesTableRow({
const handleDelete = useCallback(() => {
void (async () => {
const productName = scheduleProductName(schedule);
const confirmed = await confirmDialog({
title: "Delete schedule",
title: `Delete ${productName.toLowerCase()}`,
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
@@ -21,7 +21,10 @@ const webResizeCursorStyle = isWeb
export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
const [highlighted, setHighlighted] = useState(false);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hitAreaStyle = edge === "left" ? LEFT_HIT_AREA_STYLE : RIGHT_HIT_AREA_STYLE;
const hitAreaStyle =
edge === "left"
? [styles.hitArea, styles.leftEdge, webResizeCursorStyle]
: [styles.hitArea, styles.rightEdge, webResizeCursorStyle];
const cancelHighlightTimer = useCallback(() => {
if (highlightTimerRef.current === null) return;
@@ -84,6 +87,3 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.25,
},
}));
const LEFT_HIT_AREA_STYLE = [styles.hitArea, styles.leftEdge, webResizeCursorStyle];
const RIGHT_HIT_AREA_STYLE = [styles.hitArea, styles.rightEdge, webResizeCursorStyle];
@@ -315,7 +315,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
@@ -395,8 +397,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
},
}));
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
function StatusDotOverlay({
dotColorStyle,
size,
@@ -18,14 +18,14 @@ describe("terminal pane focus claim", () => {
it("waits for both the client and renderer before requesting a claim", () => {
const withoutClient = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isAppActivelyVisible: true,
isClientReady: false,
isConnected: true,
isRendererReady: true,
});
const withoutRenderer = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: false,
@@ -37,7 +37,7 @@ describe("terminal pane focus claim", () => {
it("does not deliver a requested claim after the host disconnects", () => {
const disconnected = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: false,
isRendererReady: true,
@@ -10,7 +10,7 @@ export interface FocusClaimStep {
interface FocusClaimReadiness {
isWorkspaceFocused: boolean;
isAppVisible: boolean;
isAppActivelyVisible: boolean;
isClientReady: boolean;
isConnected: boolean;
isRendererReady: boolean;
@@ -24,7 +24,7 @@ export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
return (
input.isWorkspaceFocused &&
input.isAppVisible &&
input.isAppActivelyVisible &&
input.isClientReady &&
input.isConnected &&
input.isRendererReady
@@ -13,7 +13,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
hasPendingTerminalModifiers,
@@ -177,7 +177,7 @@ export function TerminalPane({
onOpenWorkspaceFile,
}: TerminalPaneProps) {
const { t } = useTranslation();
const isAppVisible = useAppVisible();
const isAppActivelyVisible = useAppActivelyVisible();
const { theme } = useUnistyles();
const { settings } = useAppSettings();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
@@ -283,7 +283,7 @@ export function TerminalPane({
useEffect(() => {
const canRequest = canRequestFocusClaim({
isWorkspaceFocused,
isAppVisible,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
@@ -299,7 +299,7 @@ export function TerminalPane({
}
}, [
client,
isAppVisible,
isAppActivelyVisible,
isConnected,
isPaneFocused,
isWorkspaceFocused,
@@ -654,7 +654,7 @@ export function TerminalPane({
let sent = false;
const canSend = canRequestFocusClaim({
isWorkspaceFocused,
isAppVisible,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: true,
+15 -3
View File
@@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
HEADER_INNER_HEIGHT,
@@ -14,7 +14,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
export type ToastVariant = "default" | "success" | "error";
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
export interface ToastShowOptions {
icon?: ReactNode;
@@ -230,7 +230,9 @@ export function ToastViewport({
const toastAnimatedStyle = useMemo(
() => [
styles.toast,
toastVariant === "info" ? styles.toastInfo : null,
toastVariant === "success" ? styles.toastSuccess : null,
toastVariant === "warning" ? styles.toastWarning : null,
toastVariant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
@@ -250,8 +252,12 @@ export function ToastViewport({
}
let defaultIcon: ReactNode = null;
if (toast.variant === "success") {
if (toast.variant === "info") {
defaultIcon = <Info size={18} color={theme.colors.palette.blue[300]} />;
} else if (toast.variant === "success") {
defaultIcon = <CheckCircle2 size={18} color={theme.colors.primary} />;
} else if (toast.variant === "warning") {
defaultIcon = <AlertTriangle size={18} color={theme.colors.palette.amber[500]} />;
} else if (toast.variant === "error") {
defaultIcon = <AlertTriangle size={18} color={theme.colors.destructive} />;
}
@@ -313,6 +319,12 @@ const styles = StyleSheet.create((theme) => ({
toastSuccess: {
borderColor: theme.colors.border,
},
toastInfo: {
borderColor: theme.colors.palette.blue[300],
},
toastWarning: {
borderColor: theme.colors.palette.amber[500],
},
toastError: {
borderColor: theme.colors.destructive,
},
@@ -490,12 +490,19 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
);
}
function PlainTextSection({ text }: { text: string }) {
function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyles }) {
return (
<View style={styles.plainTextSection}>
<Text selectable style={styles.plainText}>
{text}
</Text>
<View style={styles.section}>
<ScrollView
style={ds.scrollAreaStyle}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<Text selectable style={styles.plainText}>
{text}
</Text>
</ScrollView>
</View>
);
}
@@ -576,13 +583,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct
typeof detail.input === "string" && detail.output === null ? detail.input : null;
if (plainInputText !== null) {
return [
<View key="unknown-plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.plainText}>
{plainInputText}
</Text>
</View>,
];
return [<ScrollablePlainTextSection key="unknown-plain-text" text={plainInputText} ds={ds} />];
}
const sectionsFromTopLevel = [
@@ -698,7 +699,7 @@ function buildDetailSections(
}
if (detail.type === "plain_text") {
if (!detail.text) return [];
return [<PlainTextSection key="plain-text" text={detail.text} />];
return [<ScrollablePlainTextSection key="plain-text" text={detail.text} ds={ds} />];
}
if (detail.type === "unknown") {
return buildUnknownSections(detail, ds, t);
@@ -710,7 +711,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
const { t } = useTranslation();
return (
<View style={styles.section}>
<Text style={SECTION_TITLE_ERROR_STYLE}>{t("toolCallDetails.error")}</Text>
<Text style={[styles.sectionTitle, styles.errorText]}>{t("toolCallDetails.error")}</Text>
<ScrollView
horizontal
nestedScrollEnabled
@@ -718,7 +719,11 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
<Text
selectable
style={[styles.scrollText, styles.errorText]}
dataSet={CODE_SURFACE_DATASET}
>
{errorText}
</Text>
</ScrollView>
@@ -807,10 +812,6 @@ const styles = StyleSheet.create((theme) => {
flex: 1,
minHeight: 0,
},
plainTextSection: {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
@@ -951,6 +952,3 @@ const styles = StyleSheet.create((theme) => {
},
};
});
const SECTION_TITLE_ERROR_STYLE = [styles.sectionTitle, styles.errorText];
const SCROLL_TEXT_ERROR_STYLE = [styles.scrollText, styles.errorText];
@@ -51,7 +51,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
<View style={expanded ? CHEVRON_EXPANDED_STYLE : styles.chevron}>
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
</View>
);
@@ -76,7 +76,3 @@ const styles = StyleSheet.create((theme: Theme) => ({
transform: [{ rotate: "90deg" }],
},
}));
// Stable module-level style ref so TreeChevron passes a constant array, not one created
// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo.
const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded];
+2 -4
View File
@@ -199,7 +199,7 @@ export function SearchInput({
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
resetKey={resetKey}
onChangeText={onChangeText}
@@ -212,7 +212,7 @@ export function SearchInput({
key={resetKey}
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
onChangeText={onChangeText}
@@ -1711,5 +1711,3 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "flex-end",
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, IS_WEB && { outlineStyle: "none" }];
@@ -418,7 +418,8 @@ function CopyableInfoRow({
if (copied || isHovered) {
iconUniProps = foregroundColorMapping;
}
const textStyle = copied || isHovered ? cardInfoTextHoveredCombined : styles.cardInfoText;
const textStyle =
copied || isHovered ? [styles.cardInfoText, styles.cardInfoTextHovered] : styles.cardInfoText;
return (
<Pressable
@@ -506,7 +507,9 @@ function ChecksSummaryContent({
const { t } = useTranslation();
const { passed, failed, pending } = getChecksSummaryCounts(checks);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const labelStyle = hovered
? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered]
: styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
const icon = getForgePresentation(normalizeForge(forge)).icon;
@@ -670,10 +673,3 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.statusSuccess,
},
}));
const checksSummaryLabelHoveredCombined = [
styles.checksSummaryLabel,
styles.checksSummaryLabelHovered,
];
const cardInfoTextHoveredCombined = [styles.cardInfoText, styles.cardInfoTextHovered];
@@ -62,6 +62,9 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { useCommandCenterActions } from "@/command-center/provider";
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
interface AgentControlOption {
id: string;
@@ -130,6 +133,7 @@ export interface DraftAgentControlsProps {
interface AgentControlsProps {
agentId: string;
serverId: string;
isPaneFocused: boolean;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
@@ -1362,9 +1366,11 @@ function ThinkingComboboxOption({
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
isPaneFocused,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
@@ -1436,29 +1442,49 @@ export const AgentControls = memo(function AgentControls({
const activeModelId = modelSelection.activeModelId;
const handleSelectModel = useCallback(
(modelId: string) => {
async (modelId: string) => {
if (!client || !agentProvider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agentProvider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentControls] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentControls] setAgentModel failed", error);
try {
await client.setAgentModel(agentId, modelId);
await updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agentProvider,
updates: {
model: modelId,
},
}),
);
} catch (error) {
console.warn("[AgentControls] setAgentModel or persist preference failed", error);
toast.error(toErrorMessage(error));
});
}
},
[agentId, agentProvider, client, toast, updatePreferences],
);
const commandCenterModelActions = useMemo(
() =>
buildModelChoiceContributions({
serverId,
providers: agentModelSelectorProviders,
selectedProvider: agentProvider ?? null,
selectedModelId: activeModelId,
groupLabel: t("shell.commandCenter.modelGroupLabel"),
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
getIcon: getCommandCenterProviderIcon,
select: (_provider, modelId) => handleSelectModel(modelId),
}),
[activeModelId, agentModelSelectorProviders, agentProvider, handleSelectModel, serverId, t],
);
useCommandCenterActions({
sourceId: `agent:${serverId}:${agentId}`,
enabled: isPaneFocused && Boolean(client),
actions: commandCenterModelActions,
});
const handleToggleFavoriteModel = useCallback(
(provider: string, modelId: string) => {
void updatePreferences((current) =>
@@ -24,6 +24,9 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useCommandCenterActions } from "@/command-center/provider";
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
@@ -375,6 +378,28 @@ export function WorkspaceDraftAgentTab({
if (!composerState) {
throw new Error("Workspace draft composer state is required");
}
const draftModelActions = useMemo(
() =>
buildModelChoiceContributions({
serverId,
providers: composerState.modelSelectorProviders,
selectedProvider: composerState.selectedProvider,
selectedModelId: composerState.effectiveModelId || null,
groupLabel: t("shell.commandCenter.modelGroupLabel"),
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
getIcon: getCommandCenterProviderIcon,
select: composerState.setProviderAndModelFromUser,
}),
[
composerState.effectiveModelId,
composerState.modelSelectorProviders,
composerState.selectedProvider,
composerState.setProviderAndModelFromUser,
serverId,
t,
],
);
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const setDraftAttachments = draftInput.setAttachments;
@@ -517,6 +542,11 @@ export function WorkspaceDraftAgentTab({
onCreated(result);
},
});
useCommandCenterActions({
sourceId: `draft:${serverId}:${tabId}`,
enabled: isPaneFocused && !isSubmitting,
actions: draftModelActions,
});
const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit &&
+6 -5
View File
@@ -258,10 +258,11 @@ interface RenderLeftContentArgs {
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
isPaneFocused: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
@@ -269,6 +270,7 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
<AgentControls
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
@@ -568,7 +570,7 @@ function QueuedMessageRow({
</Pressable>
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
style={[styles.queueActionButton, styles.queueSendButton]}
accessibilityLabel={sendNowLabel}
accessibilityRole="button"
>
@@ -1845,8 +1847,9 @@ export function Composer({
serverId,
focusInput,
isCompactLayout,
isPaneFocused,
}),
[agentControls, agentId, focusInput, isCompactLayout, serverId],
[agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -2234,8 +2237,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
})) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
+21 -26
View File
@@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
@@ -68,6 +67,7 @@ import {
} from "./labels";
import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
@@ -1290,7 +1290,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
@@ -1407,6 +1406,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
const showOverlay = showDictationOverlay || showRealtimeOverlay;
const surfacePresentation = resolveComposerSurfacePresentation(showOverlay);
useEffect(() => {
if (isDictating || isDictationProcessing) {
@@ -1427,22 +1427,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
);
// Animate overlay
useEffect(() => {
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
});
}, [overlayTransition, showOverlay]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayTransition.value,
pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none",
}));
const inputAnimatedStyle = useAnimatedStyle(() => ({
opacity: 1 - overlayTransition.value,
}));
const handleVoicePress = useCallback(
() =>
handleVoicePressImpl({
@@ -1760,8 +1744,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [handleStopRealtimeVoice]);
const inputWrapperCombinedStyle = useMemo(
() => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle],
[inputWrapperStyle, inputAnimatedStyle],
() => [
styles.inputWrapper,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
],
[inputWrapperStyle, surfacePresentation.input.opacity],
);
const textInputStyle = useMemo(
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
@@ -1772,8 +1760,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[isSendButtonDisabled],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, overlayAnimatedStyle],
[overlayAnimatedStyle],
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
[surfacePresentation.overlay.opacity],
);
const renderAttachButtonIcon = useCallback(
@@ -1802,7 +1790,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
<View
ref={inputWrapperRef}
style={inputWrapperCombinedStyle}
pointerEvents={surfacePresentation.input.pointerEvents}
>
{attachmentSlot}
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
@@ -1881,9 +1873,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
</View>
</View>
</Animated.View>
</View>
<Animated.View style={overlayContainerStyle}>
<View
style={overlayContainerStyle}
pointerEvents={surfacePresentation.overlay.pointerEvents}
>
<MessageInputOverlay
showDictationOverlay={showDictationOverlay}
showRealtimeOverlay={showRealtimeOverlay}
@@ -1901,7 +1896,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onDiscardFailedRecording={handleDiscardFailedRecording}
onRealtimeVoiceStop={handleRealtimeVoiceStop}
/>
</Animated.View>
</View>
</View>
);
},
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
@@ -9,6 +10,22 @@ import {
const connected = { isConnected: true } as never;
const disconnected = { isConnected: false } as never;
describe("composer surface presentation", () => {
it("shows only the input when no voice overlay is active", () => {
expect(resolveComposerSurfacePresentation(false)).toEqual({
input: { opacity: 1, pointerEvents: "auto" },
overlay: { opacity: 0, pointerEvents: "none" },
});
});
it("shows only the voice overlay while voice UI is active", () => {
expect(resolveComposerSurfacePresentation(true)).toEqual({
input: { opacity: 0, pointerEvents: "none" },
overlay: { opacity: 1, pointerEvents: "auto" },
});
});
});
describe("computeCanStartDictation", () => {
it("returns false when socket is disconnected", () => {
expect(
+26
View File
@@ -3,6 +3,32 @@ import type { MessagePayload } from "@/composer/types";
export type SendBehavior = "interrupt" | "queue";
interface ComposerSurfaceState {
opacity: 0 | 1;
pointerEvents: "auto" | "none";
}
export interface ComposerSurfacePresentation {
input: ComposerSurfaceState;
overlay: ComposerSurfaceState;
}
const INPUT_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 1, pointerEvents: "auto" },
overlay: { opacity: 0, pointerEvents: "none" },
};
const OVERLAY_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 0, pointerEvents: "none" },
overlay: { opacity: 1, pointerEvents: "auto" },
};
export function resolveComposerSurfacePresentation(
showOverlay: boolean,
): ComposerSurfacePresentation {
return showOverlay ? OVERLAY_PRESENTATION : INPUT_PRESENTATION;
}
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
revalidateSessionAfterResume,
SESSION_STALE_AFTER_MS,
} from "./session-resume-revalidation";
describe("session resume revalidation", () => {
it("refreshes both directories and timeline history after a stale resume", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(true);
expect(calls).toEqual(["history:server", "directories"]);
});
it("does nothing after a brief background interval", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS - 1,
serverId: "server",
bumpHistorySyncGeneration: () => calls.push("history"),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(false);
expect(calls).toEqual([]);
});
});
@@ -0,0 +1,16 @@
export const SESSION_STALE_AFTER_MS = 60_000;
export async function revalidateSessionAfterResume(input: {
awayMs: number;
serverId: string;
bumpHistorySyncGeneration: (serverId: string) => void;
refreshDirectories: () => Promise<unknown>;
}): Promise<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
}
@@ -0,0 +1,63 @@
import { expect, it } from "vitest";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "./session-workspace-upserts";
import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation";
const SERVER_ID = "workspace-directory-reconciliation";
function workspace(id: string, title: string): WorkspaceDescriptorPayload {
return {
id,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
title,
status: "done",
activityAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
it("keeps workspace upserts and removals received during later pages", () => {
const result = reconcileWorkspaceDirectory({
serverId: SERVER_ID,
snapshot: new Map([
["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))],
["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))],
]),
deltas: [
{ kind: "upsert", workspace: workspace("updated", "live") },
{ kind: "remove", id: "removed" },
],
});
expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([
["updated", "live"],
]);
});
it("does not restore a locally archiving workspace from a buffered upsert", () => {
markWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" });
try {
const result = reconcileWorkspaceDirectory({
serverId: SERVER_ID,
snapshot: new Map(),
deltas: [{ kind: "upsert", workspace: workspace("archiving", "live") }],
});
expect(result.has("archiving")).toBe(false);
} finally {
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" });
}
});
@@ -0,0 +1,26 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts";
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
export function reconcileWorkspaceDirectory(input: {
serverId: string;
snapshot: ReadonlyMap<string, WorkspaceDescriptor>;
deltas: readonly WorkspaceDelta[];
}): Map<string, WorkspaceDescriptor> {
const workspaces = new Map(input.snapshot);
for (const delta of input.deltas) {
if (delta.kind === "remove") {
workspaces.delete(delta.id);
} else {
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
}
}
}
return workspaces;
}
@@ -246,7 +246,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
@@ -258,7 +258,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
accessibilityLabel={t("desktop.daemon.management.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
@@ -270,7 +270,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
<Text style={settingsStyles.rowHint}>
@@ -294,7 +294,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</Button>
</View>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
@@ -420,7 +420,7 @@ export function LocalDaemonSection() {
testID="host-page-daemon-lifecycle-card"
>
{isLoading || isLoadingSettings ? (
<View style={LOADING_CARD_STYLE}>
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : (
@@ -524,7 +524,5 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
@@ -16,11 +16,11 @@ import {
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
import { conductorImportSource } from "@/desktop/imports/conductor";
import { ExternalImport } from "@/desktop/imports/external-import";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
add: "settings.integrations.operations.add",
@@ -183,7 +183,7 @@ export function IntegrationsSection() {
</Button>
)}
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
@@ -203,6 +203,7 @@ export function IntegrationsSection() {
onUninstall={handleUninstallSkills}
/>
</View>
<ExternalImport source={conductorImportSource} />
</View>
</SettingsSection>
);
@@ -17,7 +17,11 @@ interface DaemonStatusData {
logs: DesktopDaemonLogs;
}
export function useDaemonStatus() {
interface UseDaemonStatusOptions {
refetchOnMount?: boolean | "always";
}
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnMount: options.refetchOnMount ?? "always",
retry: false,
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
@@ -1,8 +1,6 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { getIsElectron } from "@/constants/platform";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
const isElectron = getIsElectron();
const localDaemon = useLocalDaemonServerIdState();
const hosts = useHosts();
const { settings, updateSettings } = useDesktopSettings();
const { data, setStatus, refetch } = useDaemonStatus();
const { enable } = useBuiltInDaemonManagement({
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
refreshStatus: refetch,
});
const isLocalhostConfigured =
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId);
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
const visible = isElectron && data?.status.desktopManaged === false;
const onPress = useCallback(() => {
void (async () => {
+14
View File
@@ -165,6 +165,19 @@ export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export type DesktopImportOutput =
| {
runId: string;
type: "event";
event: { level: "info" | "warning" | "error"; message: string };
}
| { runId: string; type: "status"; succeeded: boolean };
export interface DesktopImportsBridge {
run?: (input: { source: string }) => Promise<{ runId: string }>;
onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
@@ -178,6 +191,7 @@ export interface DesktopHostBridge {
webUtils?: DesktopWebUtilsBridge;
menu?: DesktopMenuBridge;
browser?: DesktopBrowserBridge;
imports?: DesktopImportsBridge;
}
declare global {
@@ -0,0 +1,116 @@
<svg width="115" height="174" viewBox="0 0 115 174" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="36" width="23" height="29">
<path d="M0.499023 63.6992V37.251H22.373V63.6992H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask0_41_20251)">
<path d="M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask1_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="36" width="24" height="29">
<path d="M18.7979 63.6992V37.251H40.6719V63.6992H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask1_41_20251)">
<path d="M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask2_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="73" width="23" height="28">
<path d="M0.499023 100.297V73.8486H22.373V100.297H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask2_41_20251)">
<path d="M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask3_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="73" width="24" height="28">
<path d="M18.7979 100.297V73.8486H40.6719V100.297H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask3_41_20251)">
<path d="M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask4_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="109" width="23" height="29">
<path d="M0.499023 136.896V110.447H22.373V136.896H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask4_41_20251)">
<path d="M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask5_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="109" width="24" height="29">
<path d="M18.7979 136.896V110.447H40.6719V136.896H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask5_41_20251)">
<path d="M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask6_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="146" width="24" height="28">
<path d="M18.7979 173.493V147.045H40.6719V173.493H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask6_41_20251)">
<path d="M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask7_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="146" width="24" height="28">
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask7_41_20251)">
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask8_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="146" width="24" height="28">
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask8_41_20251)">
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask9_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="146" width="24" height="28">
<path d="M73.6963 173.493V147.045H95.5703V173.493H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask9_41_20251)">
<path d="M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask10_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="109" width="24" height="29">
<path d="M73.6963 136.896V110.447H95.5703V136.896H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask10_41_20251)">
<path d="M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask11_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="109" width="24" height="29">
<path d="M91.9932 136.896V110.447H113.867V136.896H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask11_41_20251)">
<path d="M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask12_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="0" width="24" height="28">
<path d="M18.7979 27.1006V0.652344H40.6719V27.1006H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask12_41_20251)">
<path d="M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask13_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="0" width="24" height="28">
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask13_41_20251)">
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask14_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="0" width="24" height="28">
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask14_41_20251)">
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask15_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="0" width="24" height="28">
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask15_41_20251)">
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask16_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="0" width="24" height="28">
<path d="M91.9932 27.1006V0.652344H113.867V27.1006H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask16_41_20251)">
<path d="M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask17_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="36" width="24" height="29">
<path d="M73.6963 63.6992V37.251H95.5703V63.6992H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask17_41_20251)">
<path d="M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask18_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="36" width="24" height="29">
<path d="M91.9932 63.6992V37.251H113.867V63.6992H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask18_41_20251)">
<path d="M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -0,0 +1,8 @@
import type { ImportSourceDescriptor } from "./import-sheet";
export const conductorImportSource: ImportSourceDescriptor = {
id: "conductor",
title: "Conductor",
// Official light letter mark from https://www.conductor.build/brandkit.
icon: require("./conductor.svg"),
};
@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isElectronRuntimeMac } from "@/desktop/host";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { settingsStyles } from "@/styles/settings";
import { ImportSheet, type ImportSourceDescriptor } from "./import-sheet";
export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
const { t } = useTranslation();
const { data } = useDaemonStatus({ refetchOnMount: false });
const desktopManaged = data?.status.desktopManaged;
const [visible, setVisible] = useState(false);
const open = useCallback(() => setVisible(true), []);
const close = useCallback(() => setVisible(false), []);
if (!isElectronRuntimeMac()) return null;
const available = desktopManaged === true;
let reason: string | null = null;
if (desktopManaged === undefined) {
reason = t("desktop.integrations.import.availability.checking");
} else if (!available) {
reason = t("desktop.integrations.import.availability.host-not-running");
}
return (
<>
<View
style={[settingsStyles.row, settingsStyles.rowBorder]}
testID={`${source.id}-import-row`}
>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<View style={styles.iconFrame}>
<Image source={source.icon} style={styles.icon} resizeMode="contain" />
</View>
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{reason ?? t("desktop.integrations.import.description")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={open}
disabled={!available}
testID={`${source.id}-import-open`}
>
{t("desktop.integrations.import.actions.import")}
</Button>
</View>
<ImportSheet source={source} visible={visible} onClose={close} />
</>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
iconFrame: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
backgroundColor: "#282423",
},
icon: { width: 12, height: 18 },
}));

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