Compare commits

...

41 Commits

Author SHA1 Message Date
Mohamed Boudra
25a6f204a4 chore(release): cut 0.1.4 2026-02-14 13:52:34 +07:00
Mohamed Boudra
deeba6d6eb feat: add voice capability status reporting to client 2026-02-14 13:47:07 +07:00
Mohamed Boudra
a644adbf9b feat: add voice capability status reporting to client 2026-02-14 13:14:57 +07:00
Mohamed Boudra
c4bebb97ab Improve connection selection and agent hydration reliability 2026-02-14 11:31:57 +07:00
Mohamed Boudra
21f191583f refactor(server): centralize daemon-client correlated RPC wiring
What changed:
- Added private  helper in  to centralize request-id resolution, schema parsing, and correlated RPC dispatch defaults.
- Migrated checkout/worktree/branch/file-explorer/provider/speech/command request methods to use the helper while preserving existing message types and per-method timeouts.
- Added a deterministic unit test covering  request/response wiring.

Reasoning:
- The prior implementation duplicated the same correlated-RPC boilerplate across many methods, increasing maintenance cost and risk of drift.
- Centralizing this path reduces logic density and keeps transport behavior consistent.
- Additional test coverage protects one of the checkout RPC flows touched by the refactor.

Accomplishments for next agent:
- Reduced repetitive correlated RPC scaffolding in  without changing external behavior.
- Verified with workspace typecheck and daemon-client test suite.

Challenges / follow-ups for next agent:
-  remains very large; this commit intentionally scoped to request plumbing only.
- Next high-value split is extracting cohesive domains (for example checkout/worktree flows) behind focused modules while keeping wire contracts unchanged.
2026-02-14 10:06:51 +07:00
Mohamed Boudra
d9e14186ce refactor(server): extract terminal stream state manager from daemon client
Move terminal stream buffering, handler fanout, and ack offset state out of DaemonClient into a focused TerminalStreamManager.\n\nReasoning:\n- daemon-client.ts mixed RPC transport concerns with terminal stream state machine details.\n- terminal buffering/ack logic is cohesive and testable in isolation, so splitting it lowers logic density and makes future stream changes safer.\n\nWhat changed:\n- Added daemon-client-terminal-stream-manager.ts to own stream handlers, buffered chunks, and ack monotonicity.\n- Updated DaemonClient to delegate terminal stream state transitions (subscribe, receive chunk, clear stream/all, note ack).\n- Preserved public API by re-exporting TerminalStreamChunk from daemon-client.ts.\n- Hardened daemon-client tests to assert behavior (no delivery/ack after detach or stream_exit) rather than private internal maps.\n- Added dedicated manager unit tests for buffering, eviction, handler-failure behavior, and stale ack suppression.\n\nVerification:\n- npm run test --workspace=@getpaseo/server -- src/client/daemon-client.test.ts src/client/daemon-client-terminal-stream-manager.test.ts\n- npm run typecheck\n\nNext-agent notes:\n- Accomplishment: terminal stream state is now isolated; daemon-client.ts is slimmer and easier to extend.\n- Challenge: daemon-client.ts is still large (~3k lines) with other cohesive seams remaining (dictation flow and checkout RPC groups).
2026-02-14 09:51:49 +07:00
Mohamed Boudra
c87c206900 refactor(server): split daemon client transport into focused modules
Why:\n- daemon-client-transport.ts had multiple unrelated concerns (runtime WebSocket adaptation, relay/e2ee orchestration, and message/ID utility helpers) in one dense file.\n- This made control flow harder to follow and increased coupling for future edits in server transport code.\n\nWhat changed:\n- Added transport type contracts in packages/server/src/client/daemon-client-transport-types.ts.\n- Moved websocket-specific factory/binding logic to packages/server/src/client/daemon-client-websocket-transport.ts.\n- Moved relay/e2ee handshake + encrypted transport orchestration to packages/server/src/client/daemon-client-relay-e2ee-transport.ts.\n- Moved payload normalization, close/error description, id generation, and encode/decode helpers to packages/server/src/client/daemon-client-transport-utils.ts.\n- Converted packages/server/src/client/daemon-client-transport.ts into a compatibility barrel that re-exports the existing API so daemon-client callsites remain unchanged.\n- Reduced nested payload conversion logic by introducing shared normalization helpers used by relay/e2ee paths.\n\nVerification:\n- npm run -w packages/server test -- src/client/daemon-client-transport.test.ts\n- npm run -w packages/server test -- src/client/daemon-client.test.ts\n- npm run typecheck\n\nNext-agent notes (accomplishments/challenges):\n- Accomplishment: transport responsibilities are now separated by concern, with no consumer API break in daemon-client.ts.\n- Accomplishment: existing transport and daemon-client tests pass unchanged after the split.\n- Challenge to watch: createEncryptedTransport remains stateful by design (opened/closed/channel lifecycle); future changes should keep this sequencing behavior identical and covered by deterministic tests.
2026-02-14 09:38:02 +07:00
Mohamed Boudra
b1956cffb7 refactor(app): remove legacy stream buffer adapter
Removed the deprecated  and  compatibility API from . Runtime app code already uses the canonical head/tail reducer, so this dead path added maintenance overhead and duplicated behavior.\n\nReplaced the mixed compatibility test suite with a focused  that verifies the canonical  flow (head accumulation, flush on non-streamable events, completion flushing, and no-op reference stability).\n\nVerification:\n- npm run test --workspace=@getpaseo/app -- src/types/stream.test.ts src/types/stream-event.test.ts src/types/stream.harness.test.ts\n- npm run typecheck\n\nFor next agent:\n- Accomplished: removed legacy stream buffer migration shim and aligned tests to the canonical model only.\n- Challenge/Watchout: if any external consumer still imports removed symbols, it will now fail at compile-time; current repo has no remaining references.
2026-02-14 09:25:18 +07:00
Mohamed Boudra
fca2776e61 refactor(server): extract agent attention notification policy
What changed:
- Extracted client/push attention decision rules from websocket-server into a dedicated agent-attention-policy module.
- Replaced websocket-server inline branch logic with calls to computeShouldNotifyClient and computeShouldSendPush.
- Added deterministic unit tests that cover each notification branch and push suppression rule.

Why:
- The websocket server carried dense policy branching mixed with transport/session concerns.
- Pulling policy into a focused module lowers logic density and makes future changes safer without altering behavior.
- Direct unit tests provide confidence for future edits without requiring websocket wiring.

Notes for next agent:
- This commit intentionally preserves existing behavior and only restructures policy ownership.
- If attention behavior needs product changes, update tests in agent-attention-policy.test.ts first.
- websocket-server.relay-reconnect.test.ts still covers transport/session reconnection behavior separately.
2026-02-14 09:13:15 +07:00
Mohamed Boudra
8ca134f1a5 chore(scripts): broaden refactor loop scope and focus on server/app code 2026-02-14 09:04:17 +07:00
Mohamed Boudra
ccaeb3de21 refactor(server): split daemon client transport helpers into module
Reasoning:\n- daemon-client.ts had transport construction, ws adapter, relay e2ee wrapper, encoding helpers, and core client behavior mixed in one class file.\n- extracted transport/encoding concerns to a dedicated module to reduce logic density and tighten boundaries while preserving runtime behavior.\n\nAccomplishments:\n- added packages/server/src/client/daemon-client-transport.ts with websocket transport factory, relay e2ee transport wrapper, and transport utility helpers.\n- kept daemon-client public transport type exports stable by re-exporting types from the new module.\n- removed duplicated helper implementations from daemon-client.ts and replaced them with imports.\n- added focused tests in packages/server/src/client/daemon-client-transport.test.ts for websocket adapter behavior and helper normalization/encoding paths.\n- validated with @getpaseo/server typecheck and targeted daemon-client test suite.\n\nChallenges / handoff notes for next agent:\n- daemon-client.ts is still large and retains agent config resolution + high-level RPC orchestration concerns; next safe split is request/response waiter coordination or terminal stream buffering into dedicated modules.\n- a pre-existing unrelated workspace modification remains in scripts/codex-refactor-loop.sh and was intentionally left untouched.
2026-02-14 09:01:01 +07:00
Mohamed Boudra
8e5d2805c1 refactor(server): centralize correlated RPC response matching in daemon client
What changed:
- Added a private sendCorrelatedRequest helper in daemon-client to centralize requestId-correlated response selection.
- Migrated a large contiguous set of RPC methods to the helper: checkout/worktree/branch operations, file explorer/token/icon, provider+speech+commands RPCs, wait_for_finish, and terminal list/create/subscribe/kill/attach/detach.
- Preserved existing behavior including special-case filtering for checkout subscriptionId.

Reasoning:
- daemon-client had repeated type+requestId matching blocks across many methods, increasing maintenance cost and decision-point sprawl.
- This refactor keeps transport behavior unchanged while reducing duplicated control flow and making future RPC additions less error-prone.

Verification:
- npm run -w packages/server test -- src/client/daemon-client.test.ts
- npm run -w packages/server typecheck
- npm run typecheck

Notes for next agent:
- There are still many non-migrated sendRequest callsites in daemon-client (notably agent lifecycle/interaction and some status-typed selectors) that can be moved to the same helper in follow-up refactors.
- I hit TS generic narrowing limits while extracting the helper and solved it via a constrained correlated message type + explicit payload narrowing cast; behavior remains covered by existing daemon-client tests.
2026-02-14 08:46:38 +07:00
Mohamed Boudra
d6f21b3568 refactor(cli): centralize worktree ls path resolution
What I changed:\n- Refactored worktree path handling in worktree ls into focused helpers: resolvePaseoHomePath, resolvePaseoWorktreesDir, and isAgentInManagedWorktree.\n- Switched worktree name extraction to node:path basename for clearer intent and path handling.\n- Added a focused regression test (20-worktree-ls-paths.test.ts) covering explicit PASEO_HOME and fallback home resolution behavior.\n\nReasoning:\n- The prior implementation repeated path derivation inline inside the agent loop, mixed decision logic with mapping, and relied on string concatenation for filesystem boundaries.\n- Extracting this logic reduces cognitive load, keeps policy in one place, and makes behavior directly testable without daemon dependencies.\n\nVerification:\n- npx tsx packages/cli/tests/20-worktree-ls-paths.test.ts\n- npx tsx packages/cli/tests/14-worktree.test.ts\n- npm run typecheck (all workspaces)\n\nNotes for next agent:\n- Similar inline PASEO_HOME/path derivation still exists in other CLI commands; these helpers can be reused to continue consistency work.\n- No behavior change intended for command output or error semantics; this was a structural-only refactor aligned with refactor-skill constraints.
2026-02-14 08:38:06 +07:00
Mohamed Boudra
86b291814b test(cli): deflake daemon command integration assertions
What changed:
- Updated packages/cli/tests/03-daemon.test.ts to match current daemon command semantics where  reports local state and exits successfully when stopped.
- Removed host-based failure assumptions from status/status --json checks and asserted deterministic  output for an isolated PASEO_HOME.
- Hardened restart coverage to use an isolated random port and explicit cleanup via .
- Added best-effort forced cleanup in  to avoid leaking a daemon process if assertions fail mid-test.

Reasoning:
- Recent CLI refactors switched  from connection-failure behavior to local PID/config introspection, so the old test expectation () became incorrect and flaky.
- The restart path can occasionally exceed graceful stop timeout immediately after spawn; forced cleanup makes the test reliable without changing product behavior.
- This is a behavior-preserving test hardening refactor aligned with the refactor skill contract.

Verification:
- npx tsx packages/cli/tests/03-daemon.test.ts
- npm run typecheck

Accomplishments for next agent:
-  now validates current status semantics and no longer depends on transient host connectivity assumptions.
- Restart coverage now cleans up deterministically, reducing local test pollution and follow-on failures.

Challenges / notes for next agent:
- Restart cleanup still depends on process management timing; this test now uses  cleanup intentionally to keep CI/dev runs stable.
- If you later tighten restart lifecycle behavior, consider adding a dedicated unit-level test around stop timeout + escalation in local daemon utilities.
2026-02-14 08:25:50 +07:00
Mohamed Boudra
1b84613be1 chore(scripts): clarify refactor skill loading instruction in codex loop 2026-02-14 08:21:09 +07:00
Mohamed Boudra
004bb0ea33 refactor(cli): unify daemon start error message handling
What changed:
- Added a shared utility `getErrorMessage(error: unknown)` in `packages/cli/src/utils/errors.ts`.
- Refactored `packages/cli/src/commands/daemon/start.ts` to use `getErrorMessage` and a local `exitWithError` helper instead of repeating inline error-message extraction + exit logic in each catch block.
- Added `packages/cli/tests/19-errors-utils.test.ts` to lock utility behavior for Error and non-Error throw values.

Reasoning:
- `runStart` had repeated branching (`err instanceof Error ? err.message : String(err)`) across multiple catch sites.
- Centralizing this keeps behavior stable while reducing duplication and cognitive overhead, aligned with the refactor skill guidance to simplify structure without changing user-visible behavior.

Verification:
- Ran `npx tsx packages/cli/tests/18-local-daemon-utils.test.ts` (pass).
- Ran `npx tsx packages/cli/tests/19-errors-utils.test.ts` (pass).
- Ran `npm run -w @getpaseo/cli typecheck` (pass).

Notes for next agent:
- Existing `scripts/codex-refactor-loop.sh` was already modified before this change and is intentionally not included.
- `packages/cli/tests/03-daemon.test.ts` currently failed in this environment because Test 3 expected daemon status failure, but command exited successfully (likely environment state dependent). This refactor does not touch that path; worth deflakifying or isolating in a follow-up.
2026-02-14 01:51:36 +07:00
Mohamed Boudra
6ff1c42132 refactor(cli): simplify agent mode validation flow
What I changed:
- extracted a single missingModeError helper in agent/mode.ts so the command no longer duplicates the same command error payload in two branches
- flattened control flow by returning early for --list and using one set-mode path guarded by explicit missing-mode validation
- tightened tests/10-agent-mode.test.ts to assert the exact missing-mode error message instead of allowing a daemon-connection fallback

Reasoning:
- this keeps behavior the same while reducing branch duplication and making the validation contract explicit
- the hardened test protects the intended precedence: missing required mode should fail before any daemon interaction

Verification:
- npm run typecheck
- (cd packages/cli && npx tsx tests/10-agent-mode.test.ts)

Notes for next agent:
- repository had a pre-existing local modification in scripts/codex-refactor-loop.sh; I intentionally did not touch or stage it
- no blockers encountered for this refactor
2026-02-14 01:39:01 +07:00
Mohamed Boudra
80f0ca08f9 refactor(cli): centralize daemon errno parsing and add utility tests
What changed:
- extracted shared readNodeErrnoCode helper in local daemon process control code
- updated isProcessRunning and signalProcess to use the shared helper
- added packages/cli/tests/18-local-daemon-utils.test.ts covering resolveTcpHostFromListen cases (numeric, host:port, unix sockets, empty/non-host)

Why:
- removes duplicated low-level error-shape branching and keeps errno handling consistent in one place
- adds deterministic coverage for pure listen parsing logic without depending on daemon runtime state

Accomplishments for next agent:
- this area now has a focused utility test entry that runs fast and is independent of daemon availability
- full workspace typecheck is clean after this change

Challenges / notes for next agent:
- packages/cli/tests/03-daemon.test.ts can be environment-sensitive when a daemon is already running (baseline in this workspace did not reliably represent daemon not running)
- existing unstaged change remains in scripts/codex-refactor-loop.sh and was intentionally left untouched
2026-02-14 01:26:54 +07:00
Mohamed Boudra
193c723714 refactor(cli): simplify agent mode command control flow
Context from recent agent work
- Prior commits focused on voice/background model gating, speech resolver typing, and timeline cursor loading.
- This change targets a separate CLI command path to avoid overlap.

What changed
- Normalized the optional mode argument once (`mode?.trim()`) and reused it.
- Removed duplicated `client.close()` calls and centralized cleanup in a `finally` block.
- Typed the daemon client variable explicitly (`Awaited<ReturnType<typeof connectToDaemon>> | undefined`).
- Kept user-facing behavior and error codes stable, including DAEMON_NOT_RUNNING and MODE_OPERATION_FAILED.

Reasoning
- The previous implementation duplicated lifecycle handling and relied on non-null assertions for mode values.
- Centralized resource cleanup lowers leak risk and makes control flow easier to audit.
- This is a behavior-preserving refactor aligned with the refactor skill contract.

Verification
- `npm run typecheck`
- `npx tsx packages/cli/tests/10-agent-mode.test.ts`

Accomplishments for next agent
- `runModeCommand` now has a single cleanup path and clearer branching for list vs set mode.
- Mode argument handling is explicit and trimmed before use.

Challenges / follow-up
- CLI output typing still requires `AnyCommandResult<any>` for mixed-shape commands due the current `withOutput` generic design.
- If desired, a future refactor can redesign output typing to support per-branch result schemas without `any`.
2026-02-14 01:23:01 +07:00
Mohamed Boudra
a04096af44 chore: add codex refactor loop script 2026-02-14 01:18:41 +07:00
Mohamed Boudra
0bbdefa7b8 chore: commit remaining local changes 2026-02-14 01:07:23 +07:00
Mohamed Boudra
b8f6115465 Merge pull request #29 from getpaseo/voice/background-downloading
Decouple onboarding from voice model downloads and gate unavailable voice starts
2026-02-14 01:04:06 +07:00
Mohamed Boudra
f1626698f4 Merge pull request #28 from getpaseo/gitif/header-scroll-rule-pr
fix(app): avoid unnecessary git diff header auto-scroll on collapse
2026-02-14 01:03:57 +07:00
Mohamed Boudra
8169134ac3 refactor timeline loading to cursor-based fetch API 2026-02-14 01:03:36 +07:00
Mohamed Boudra
cf8393407e refactor(session): simplify voice readiness gating flow 2026-02-14 00:57:02 +07:00
Mohamed Boudra
c5d7fa52b4 refactor(server): centralize speech provider resolver typing 2026-02-14 00:44:08 +07:00
Mohamed Boudra
cfd061a86d feat(voice): background local model downloads with runtime gating 2026-02-13 23:23:28 +07:00
Mohamed Boudra
c41815dcb7 fix(relay): avoid stale client timer resetting control socket 2026-02-13 19:01:51 +07:00
Mohamed Boudra
67620569ab fix(server): avoid recursive checkout diff watches on linux 2026-02-13 09:30:35 +00:00
Mohamed Boudra
69f851aa14 Update files 2026-02-13 09:09:39 +00:00
Mohamed Boudra
e9efaa8499 fix(app): avoid unnecessary git diff header auto-scroll on collapse 2026-02-13 08:50:37 +00:00
Mohamed Boudra
4948ba581a Update files 2026-02-13 05:08:26 +00:00
Mohamed Boudra
b64fa4ca93 feat(dictation): add adaptive finish timeout with server-provided budget 2026-02-13 10:15:44 +07:00
Mohamed Boudra
bf432b3d3a feat: add relay reconnect with grace period and branch suggestions 2026-02-13 09:46:40 +07:00
Mohamed Boudra
837d0fb387 feat: improve Claude agent model normalization and tool mapping 2026-02-13 00:00:57 +07:00
Mohamed Boudra
6d4d74fa3a feat(app): bootstrap default localhost connection once 2026-02-12 20:31:18 +07:00
Mohamed Boudra
d771e17efd Add icons to homepage Mac and web app CTAs 2026-02-12 19:44:51 +07:00
Mohamed Boudra
9f45aa034d ci(desktop): wire Apple signing/notarization secrets 2026-02-12 19:32:24 +07:00
Mohamed Boudra
8b6f4da70d feat: auto-linkify URLs in inline code blocks 2026-02-12 19:16:23 +07:00
Mohamed Boudra
46194af14a docs: add note about updating Mac download URL after each release 2026-02-12 19:13:57 +07:00
Mohamed Boudra
734cf7af20 chore: script release tag push and trim daemon log noise 2026-02-12 19:05:51 +07:00
159 changed files with 12867 additions and 3294 deletions

View File

@@ -95,6 +95,14 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}

View File

@@ -1 +1,2 @@
rust 1.85.1
nodejs 22.20.0

View File

@@ -150,22 +150,24 @@ Run `npx expo-doctor` to diagnose version mismatches and native module issues.
## Release playbook
Use the scripted release flow from repo root. Avoid manual version bumps or publish commands unless debugging.
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
```bash
# 1) bump all workspaces and refresh workspace links
npm run version:all:patch
# Recommended: full patch release (bump, check, publish, push branch+tag)
npm run release:patch
# 2) run release gate checks (typecheck, build, pack dry-run)
# Manual, step-by-step fallback:
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
# 3) publish relay/server/cli packages
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop release)
```
Notes:
- `release:prepare` is part of the flow and refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
## Orchestrator Mode

View File

@@ -76,11 +76,15 @@ See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
```bash
git tag v0.1.0
git push origin v0.1.0
npm run version:all:patch
npm run release:push
```
If you prefer, `npm version` can be used to create and push a version tag.
For the full package release flow, use:
```bash
npm run release:patch
```
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).

41
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.3",
"version": "0.1.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -7139,6 +7139,12 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"license": "MIT",
@@ -7146,6 +7152,15 @@
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"dev": true,
@@ -20320,7 +20335,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -20328,7 +20343,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.3",
"@getpaseo/server": "0.1.4",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -20347,6 +20362,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",
@@ -20429,11 +20446,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.3",
"@getpaseo/server": "0.1.3",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -20483,14 +20500,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.3",
"version": "0.1.4",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -20506,12 +20523,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.3",
"@getpaseo/relay": "0.1.4",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20862,7 +20879,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"workspaces": [
"packages/server",
@@ -32,14 +32,19 @@
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public"
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"concurrently": "^9.2.1",

View File

@@ -0,0 +1,34 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo();
const marker = 'TIMELINE_HYDRATION_OK';
const prompt = `Respond with exactly: ${marker}`;
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
const assistantMessage = page
.getByTestId('assistant-message')
.filter({ hasText: marker })
.first();
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await expect(
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -153,6 +153,10 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -168,9 +168,10 @@ export const openSettings = async (page: Page) => {
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
await expect(workingDirectoryLabel).toBeVisible();
const workingDirectorySelect = page.getByTestId('working-directory-select').first();
const workingDirectorySelect = page
.locator('[data-testid="working-directory-select"]:visible')
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const input = page.getByRole('textbox', { name: '/path/to/project' });
const worktreePicker = page.getByTestId('worktree-attach-picker');
@@ -333,11 +334,9 @@ export const createAgent = async (page: Page, message: string) => {
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
const userBubble = page
.getByRole('button', { name: 'Copy message' })
.filter({ hasText: message })
.first();
await expect(userBubble).toBeVisible();
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
};
export interface AgentConfig {

View File

@@ -0,0 +1,370 @@
import { test, expect, type Page } from "./fixtures";
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const modernMatch = pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (modernMatch) {
return {
serverId: decodeURIComponent(modernMatch[1]),
agentId: decodeURIComponent(modernMatch[2]),
};
}
const legacyMatch = pathname.match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (legacyMatch) {
return {
serverId: decodeURIComponent(legacyMatch[1]),
agentId: decodeURIComponent(legacyMatch[2]),
};
}
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
}
async function openAgentFromSidebar(page: Page, serverId: string, agentId: string): Promise<void> {
await gotoHome(page);
const row = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect
.poll(
() => {
try {
const parsed = parseAgentFromUrl(page.url());
return `${parsed.serverId}:${parsed.agentId}`;
} catch {
return "";
}
},
{ timeout: 30000 }
)
.toBe(`${serverId}:${agentId}`);
}
async function openNewAgentDraft(page: Page): Promise<void> {
await gotoHome(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(
page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({
timeout: 30000,
});
}
async function openTerminalsPanel(page: Page): Promise<void> {
let header = page.locator('[data-testid="explorer-header"]:visible').first();
if (!(await header.isVisible().catch(() => false))) {
const toggle = page.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
});
if (await toggle.first().isVisible().catch(() => false)) {
await toggle.first().click();
}
}
header = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(header).toBeVisible({ timeout: 30000 });
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
timeout: 30000,
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(2);
await tabs.last().click();
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function runTerminalCommandWithPreEnterEcho(
page: Page,
command: string,
expectedText: string
): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await expect(surface).toContainText(command, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
await expect
.poll(
async () =>
await page.evaluate((target) => {
const terminal = (window as any).__paseoTerminal;
if (!terminal?.buffer?.active?.getLine || !terminal?.buffer?.active?.getNullCell) {
return false;
}
const buffer = terminal.buffer.active;
const nullCell = buffer.getNullCell();
const lineCount = buffer.length ?? 0;
const cols = terminal.cols ?? 0;
for (let y = 0; y < lineCount; y += 1) {
const line = buffer.getLine(y);
if (!line) continue;
const lineText = line.translateToString(true);
const index = lineText.indexOf(target);
if (index === -1) continue;
for (let x = index; x < index + target.length && x < cols; x += 1) {
const cell = line.getCell(x, nullCell);
if (!cell) continue;
if (!cell.isFgDefault()) {
return true;
}
}
}
return false;
}, marker),
{ timeout: 30000 }
)
.toBe(true);
}
test("Terminals tab creates multiple terminals and streams command output", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminals-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page);
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
await runTerminalCommandWithPreEnterEcho(
page,
`echo ${preEnterEchoMarker}`,
preEnterEchoMarker
);
const ansiMarker = `ansi-red-${Date.now()}`;
await runTerminalCommand(
page,
`printf '\\033[31m${ansiMarker}\\033[0m\\n'`,
ansiMarker
);
await expectAnsiColorApplied(page, ansiMarker);
const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerTwo}`, markerTwo);
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent one");
const first = parseAgentFromUrl(page.url());
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent two");
const second = parseAgentFromUrl(page.url());
expect(first.serverId).toBe(second.serverId);
expect(first.agentId).not.toBe(second.agentId);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
const sharedMarker = `shared-terminal-${Date.now()}`;
await runTerminalCommand(page, `echo ${sharedMarker}`, sharedMarker);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
return typeof terminal?.rows === "number" ? terminal.rows : 0;
});
}
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
await page.evaluate((nextPadding) => {
const container = document.querySelector<HTMLElement>(
'[data-testid="explorer-content-area"]'
);
if (!container) {
return;
}
container.style.boxSizing = "border-box";
container.style.paddingBottom = nextPadding + "px";
}, padding);
}
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (
window as {
__paseoTerminal?: {
buffer?: { active?: { baseY?: unknown; viewportY?: unknown } };
};
}
).__paseoTerminal;
const baseY = terminal?.buffer?.active?.baseY;
const viewportY = terminal?.buffer?.active?.viewportY;
if (typeof baseY !== "number" || typeof viewportY !== "number") {
return 0;
}
return Math.max(0, baseY - viewportY);
});
}
test("terminal viewport resizes and uses xterm scrollback", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-viewport-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Viewport and scrollback test");
await openTerminalsPanel(page);
const initialViewport = page.viewportSize();
if (!initialViewport) {
throw new Error("Expected a viewport size");
}
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThan(0);
const initialRows = await getTerminalRows(page);
await setExplorerContentBottomPadding(page, 220);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await setExplorerContentBottomPadding(page, 0);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const reducedHeight = Math.max(520, initialViewport.height - 220);
await page.setViewportSize({
width: initialViewport.width,
height: reducedHeight,
});
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await page.setViewportSize(initialViewport);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const scrollbackMarker = `scrollback-${Date.now()}`;
await runTerminalCommand(
page,
`for i in $(seq 1 180); do echo ${scrollbackMarker}-$i; done`,
`${scrollbackMarker}-180`
);
const surface = page.getByTestId("terminal-surface").first();
await surface.hover();
await page.mouse.wheel(0, -3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeGreaterThan(0);
const distanceAfterScrollUp = await getTerminalScrollbackDistance(page);
await surface.hover();
await page.mouse.wheel(0, 3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeLessThan(distanceAfterScrollUp);
} finally {
await repo.cleanup();
}
});

View File

@@ -19,6 +19,14 @@
"env": {
"APP_VARIANT": "production"
}
},
"production-apk": {
"extends": "production",
"distribution": "internal",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
}
},
"submit": {

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"scripts": {
"start": "expo start",
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.3",
"@getpaseo/server": "0.1.4",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -49,6 +49,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",

View File

@@ -43,6 +43,10 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
polyfillCrypto();
@@ -268,53 +272,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const isLoading = settingsLoading || registryLoading;
const isInitialConnectionPending = useMemo(() => {
if (daemons.length === 0) return false;
return daemons.some((daemon) => {
const record = connectionStates.get(daemon.serverId);
if (!record) return true;
if (record.hasEverReceivedAgentList) return false;
return (
record.status === "idle" ||
record.status === "connecting" ||
(record.status === "online" && !record.agentListReady)
);
});
}, [daemons, connectionStates]);
const [connectionTimedOut, setConnectionTimedOut] = useState(false);
useEffect(() => {
if (!isInitialConnectionPending) {
setConnectionTimedOut(false);
return;
}
const timer = setTimeout(() => setConnectionTimedOut(true), 5000);
return () => clearTimeout(timer);
}, [isInitialConnectionPending]);
const connectingMessage = useMemo(() => {
if (!isInitialConnectionPending) return null;
const pending = daemons.filter((daemon) => {
const record = connectionStates.get(daemon.serverId);
if (!record) return true;
if (record.hasEverReceivedAgentList) return false;
return (
record.status === "idle" ||
record.status === "connecting" ||
(record.status === "online" && !record.agentListReady)
);
});
if (pending.length === 1) {
const label = pending[0].label?.trim();
return label ? `Connecting to ${label}...` : "Connecting...";
}
return "Connecting...";
}, [isInitialConnectionPending, daemons, connectionStates]);
// Apply theme setting on mount and when it changes
useEffect(() => {
if (isLoading) return;
@@ -330,10 +289,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return <LoadingView />;
}
if (connectingMessage && !connectionTimedOut) {
return <LoadingView message={connectingMessage} />;
}
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
@@ -359,7 +314,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace({ pathname: "/", params: { serverId } });
router.replace(buildHostAgentDraftRoute(serverId) as any);
})
.catch((error) => {
if (cancelled) return;
@@ -389,13 +344,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
// Match /agent/[serverId]/[agentId] pattern
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)\/?$/);
if (match) {
const [, serverId, agentId] = match;
return `${serverId}:${agentId}`;
}
return undefined;
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [pathname]);
return (
@@ -478,14 +428,14 @@ export default function RootLayout() {
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="agent" />
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
<Stack.Screen
name="agent/[serverId]/[agentId]"
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agent/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>

View File

@@ -1,8 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen";
export default function LegacyAgentRoute() {
const { id } = useLocalSearchParams<{ id?: string }>();
return <LegacyAgentIdScreen agentId={typeof id === "string" ? id : ""} />;
}

View File

@@ -1,16 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
export default function AgentReadyRoute() {
const { serverId, agentId } = useLocalSearchParams<{
serverId?: string;
agentId?: string;
}>();
return (
<AgentReadyScreen
serverId={typeof serverId === "string" ? serverId : ""}
agentId={typeof agentId === "string" ? agentId : ""}
/>
);
}

View File

@@ -1,6 +0,0 @@
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function AgentDraftRoute() {
return <DraftAgentScreen />;
}

View File

@@ -0,0 +1,17 @@
import { useLocalSearchParams } from "expo-router";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
export default function HostAgentReadyRoute() {
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
}>();
return (
<AgentReadyScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
agentId={typeof params.agentId === "string" ? params.agentId : ""}
/>
);
}

View File

@@ -0,0 +1,10 @@
import { useLocalSearchParams } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function HostDraftAgentRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <DraftAgentScreen forcedServerId={serverId} />;
}

View File

@@ -0,0 +1,9 @@
import { useLocalSearchParams } from "expo-router";
import { AgentsScreen } from "@/screens/agents-screen";
export default function HostAgentsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <AgentsScreen serverId={serverId} />;
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
const router = useRouter();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
useEffect(() => {
if (!serverId) {
return;
}
router.replace(buildHostAgentDraftRoute(serverId) as any);
}, [router, serverId]);
return null;
}

View File

@@ -0,0 +1,3 @@
import SettingsScreen from "@/screens/settings-screen";
export default SettingsScreen;

View File

@@ -1,5 +1,59 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function Index() {
return <DraftAgentScreen />;
const router = useRouter();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
if (!targetServerId) {
return <DraftAgentScreen />;
}
return null;
}

View File

@@ -11,6 +11,10 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { probeConnection } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostAgentDraftRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -138,8 +142,14 @@ export default function PairScanScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>();
const params = useLocalSearchParams<{
source?: string;
sourceServerId?: string;
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
@@ -160,34 +170,47 @@ export default function PairScanScreen() {
const returnToSource = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace({ pathname: "/", params: { serverId } });
router.replace(buildHostAgentDraftRoute(serverId) as any);
return;
}
if (source === "editHost" && targetServerId) {
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
// settings (default): return to previous screen
try {
router.back();
} catch {
router.replace("/settings");
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, targetServerId]
[router, source, sourceServerId, targetServerId]
);
const closeToSource = useCallback(() => {
if (source === "editHost" && targetServerId) {
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
try {
router.back();
} catch {
router.replace("/settings");
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId) as any);
return;
}
router.replace("/" as any);
}
}, [router, source, targetServerId]);
}, [router, source, sourceServerId, targetServerId]);
useEffect(() => {
if (Platform.OS === "web") return;

View File

@@ -31,6 +31,7 @@ import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -79,6 +80,7 @@ export function AgentInputArea({
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const toast = useToast();
const voice = useVoiceOptional();
const isConnected = client?.isConnected ?? false;
@@ -276,13 +278,11 @@ export function AgentInputArea({
imageAttachments?: ImageAttachment[],
forceSend?: boolean
) {
const socketConnected = isConnected;
const trimmedMessage = message.trim();
if (!trimmedMessage) return;
// When the parent controls submission (e.g. draft agent creation), let it
// decide what to do even if the socket is currently disconnected (so we
// don't no-op and lose deterministic error handling in the UI/tests).
if (!onSubmitMessageRef.current && !socketConnected) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
if (agent?.status === "running" && !forceSend) {
@@ -471,8 +471,17 @@ export function AgentInputArea({
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message && message.trim().length > 0) {
toast.error(message);
}
});
}, [agentId, isConnected, serverId, voice]);
}, [agentId, isConnected, serverId, toast, voice]);
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
@@ -485,7 +494,7 @@ export function AgentInputArea({
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item || !isConnected) return;
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));

View File

@@ -28,6 +28,10 @@ import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -114,12 +118,12 @@ export function AgentList({
params: { serverId, agentId },
});
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(`/agent/${serverId}/${agentId}` as any);
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
@@ -233,7 +233,7 @@ export function DictationOverlay({
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
>
<Check
<Pencil
size={20}
color={theme.colors.palette.white}
strokeWidth={2.5}

View File

@@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -20,9 +22,34 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400;
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log("[TerminalDebug][ExplorerSidebar] " + message, payload);
return;
}
console.log("[TerminalDebug][ExplorerSidebar] " + message);
}
interface ExplorerSidebarProps {
serverId: string;
agentId: string;
@@ -42,6 +69,14 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const terminalDebugEnabled = isTerminalDebugEnabled();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const closeGestureLastLogX = useSharedValue(0);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
useEffect(() => {
if (isMobile) {
@@ -77,6 +112,39 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
closeToAgent();
}, [closeToAgent]);
const logCloseGesture = useCallback(
(phase: string, payload?: Record<string, unknown>) => {
logTerminalDebug("close gesture " + phase, {
isMobile,
isOpen,
explorerTab,
...(payload ?? {}),
});
},
[explorerTab, isMobile, isOpen]
);
const logKeyboardInset = useCallback(
(rawHeight: number, shift: number, inset: number) => {
logTerminalDebug("keyboard inset", {
rawHeight: Math.round(rawHeight),
shift: Math.round(shift),
inset: Math.round(inset),
isMobile,
isOpen,
explorerTab,
});
},
[explorerTab, isMobile, isOpen]
);
useEffect(() => {
if (!terminalDebugEnabled) {
return;
}
logTerminalDebug("close gesture config", { isMobile, isOpen, explorerTab });
}, [explorerTab, isMobile, isOpen, terminalDebugEnabled]);
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTab(tab);
@@ -84,6 +152,30 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
[setExplorerTab]
);
useAnimatedReaction(
() => {
const rawHeight = Math.abs(keyboardHeight.value);
return {
rawHeight,
shift: Math.max(0, rawHeight - bottomInset.value),
inset: bottomInset.value,
};
},
(next, previous) => {
if (!terminalDebugEnabled) {
return;
}
if (
previous &&
Math.abs(previous.shift - next.shift) < 4 &&
Math.abs(previous.rawHeight - next.rawHeight) < 4
) {
return;
}
runOnJS(logKeyboardInset)(next.rawHeight, next.shift, next.inset);
}
);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
@@ -97,6 +189,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
.failOffsetY([-10, 10])
.onStart(() => {
isGesturing.value = true;
if (terminalDebugEnabled) {
closeGestureLastLogX.value = 0;
runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) });
}
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
@@ -104,11 +200,30 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
if (
terminalDebugEnabled &&
Math.abs(newTranslateX - closeGestureLastLogX.value) >= 80
) {
closeGestureLastLogX.value = newTranslateX;
runOnJS(logCloseGesture)("update", {
translationX: Math.round(event.translationX),
appliedTranslateX: Math.round(newTranslateX),
velocityX: Math.round(event.velocityX),
});
}
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("end", {
translationX: Math.round(event.translationX),
velocityX: Math.round(event.velocityX),
shouldClose,
});
}
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
@@ -118,18 +233,25 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
})
.onFinalize(() => {
isGesturing.value = false;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("finalize");
}
}),
[
isMobile,
isOpen,
explorerTab,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
logCloseGesture,
isGesturing,
closeGestureRef,
closeGestureLastLogX,
terminalDebugEnabled,
]
);
@@ -171,6 +293,14 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
});
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
@@ -190,8 +320,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
{ width: windowWidth, paddingTop: insets.top },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
pointerEvents="auto"
>
@@ -278,6 +409,7 @@ function SidebarContent({
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
testID="explorer-tab-changes"
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
@@ -292,6 +424,7 @@ function SidebarContent({
</Pressable>
)}
<Pressable
testID="explorer-tab-files"
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
@@ -304,6 +437,20 @@ function SidebarContent({
Files
</Text>
</Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
activeTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
</Text>
</Pressable>
</View>
<View style={styles.headerRightSection}>
{isMobile && (
@@ -322,6 +469,9 @@ function SidebarContent({
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{activeTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
)}
</View>
</View>
);

View File

@@ -7,6 +7,7 @@ import {
Pressable,
FlatList,
Platform,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native";
@@ -40,6 +41,7 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { Fonts } from "@/constants/theme";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import {
DropdownMenu,
DropdownMenuContent,
@@ -49,6 +51,7 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
// =============================================================================
// Git Actions Data Structure
@@ -518,6 +521,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
@@ -622,6 +627,18 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
bodyHeightByPathRef.current[path] = height;
}, []);
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
}, []);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
@@ -645,9 +662,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const isCurrentlyExpanded = expandedByPath[path] ?? false;
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
const shouldAnchor =
isCurrentlyExpanded &&
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
});
// Anchor to the clicked header before collapsing so visual context is preserved.
if (isCurrentlyExpanded && targetOffset !== null) {
if (shouldAnchor && targetOffset !== null) {
diffListRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
@@ -800,7 +827,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
setActionError(null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace("/agent" as any);
router.replace(buildHostAgentDraftRoute(serverId) as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
@@ -931,6 +958,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
scrollEventThrottle={16}
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.

View File

@@ -31,7 +31,10 @@ import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -133,6 +136,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
ref
) {
const { theme } = useUnistyles();
const toast = useToast();
const voice = useVoiceOptional();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -166,6 +170,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
useCallback(
(state) => {
if (!voiceServerId) {
return null;
}
return state.sessions[voiceServerId]?.serverInfo ?? null;
},
[voiceServerId]
)
);
useEffect(() => {
valueRef.current = value;
@@ -211,10 +226,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
console.error("[MessageInput] Dictation error:", error);
}, []);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
});
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false;
return socketConnected && !disabled;
}, [client, disabled]);
return socketConnected && !disabled && !dictationUnavailableMessage;
}, [client, disabled, dictationUnavailableMessage]);
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false;
@@ -267,6 +287,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = false;
}, [dictationStatus, isDictating, isDictationProcessing]);
const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) {
toast.error(dictationUnavailableMessage);
return;
}
await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]);
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
useEffect(() => {
if (!IS_WEB) return;
@@ -297,6 +325,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message && message.trim().length > 0) {
toast.error(message);
}
});
};
@@ -345,7 +382,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
void startDictationIfAvailable();
}
return;
}
@@ -366,7 +403,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isConnected,
isRealtimeVoiceForCurrentAgent,
isScreenFocused,
startDictation,
startDictationIfAvailable,
toast,
voiceAgentId,
voiceServerId,
voice,
@@ -397,13 +435,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (isDictating) {
await cancelDictation();
} else {
await startDictation();
await startDictationIfAvailable();
}
}, [
cancelDictation,
isDictating,
isRealtimeVoiceForCurrentAgent,
startDictation,
startDictationIfAvailable,
voice,
]);
@@ -461,11 +499,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message && message.trim().length > 0) {
toast.error(message);
}
});
}, [
disabled,
handleStopRealtimeVoice,
isConnected,
toast,
voice,
voiceAgentId,
voiceServerId,
@@ -618,7 +666,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
void startDictationIfAvailable();
}
return;
}
@@ -731,7 +779,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={
!isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled
!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled
}
onKeyPress={
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
@@ -836,24 +884,22 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
!isConnected ||
<TooltipTrigger
onPress={handleSendMessage}
disabled={
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(!isConnected ||
isSubmitDisabled ||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />

View File

@@ -272,6 +272,10 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontFamily: Fonts.mono,
fontSize: 13,
},
markdownCodeInlineLink: {
color: theme.colors.primary,
textDecorationLine: "underline",
},
// Used in custom markdownRules for path chip styling
pathChip: {
backgroundColor: theme.colors.surface2,
@@ -288,6 +292,30 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
}
const matches = markdownParser.linkify.match(trimmed) as
| Array<{ index: number; lastIndex: number; url: string }>
| null;
if (!matches || matches.length !== 1) {
return null;
}
const [match] = matches;
if (!match || match.index !== 0 || match.lastIndex !== trimmed.length) {
return null;
}
return match.url;
}
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
container: {
alignSelf: "flex-start",
@@ -584,13 +612,35 @@ export const AssistantMessage = memo(function AssistantMessage({
? parseInlinePathToken(content)
: null;
if (!parsed) {
if (parsed) {
return (
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
>
{content}
</Text>
);
}
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
if (inlineCodeLinkUrl) {
return (
<Text
key={node.key}
accessibilityRole="link"
onPress={() => {
handleLinkPress(inlineCodeLinkUrl);
}}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
assistantMessageStylesheet.markdownCodeInlineLink,
]}
>
{content}
@@ -601,11 +651,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
>
{content}
@@ -658,7 +706,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [onInlinePathPress]);
}, [handleLinkPress, markdownParser, onInlinePathPress]);
return (
<View

View File

@@ -1,18 +1,25 @@
import { SessionProvider } from "@/contexts/session-context";
import {
useDaemonRegistry,
type HostConnection,
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
import { probeConnection } from "@/utils/test-daemon-connection";
import { useEffect, useMemo, useRef, useState } from "react";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import {
selectBestConnection,
type ConnectionCandidate,
type ConnectionProbeState,
} from "@/utils/connection-selection";
import { useQueries } from "@tanstack/react-query";
import { useMemo } from "react";
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
type Candidate = {
connection: HostConnection;
connectionId: string;
url: string;
activeConnection: ActiveConnection;
@@ -31,28 +38,27 @@ function sortConnectionsByPreference<T extends { id: string }>(
function buildCandidates(host: HostProfile): Candidate[] {
const preferred = host.preferredConnectionId ?? null;
const direct = sortConnectionsByPreference(
host.connections.filter((c) => c.type === "direct"),
preferred
);
const relay = sortConnectionsByPreference(
host.connections.filter((c) => c.type === "relay"),
preferred
);
const connections = sortConnectionsByPreference(host.connections, preferred);
const out: Candidate[] = [];
for (const conn of direct) {
out.push({
connectionId: conn.id,
url: buildDaemonWebSocketUrl(conn.endpoint),
activeConnection: { type: "direct", endpoint: conn.endpoint, display: conn.endpoint },
});
}
for (const conn of connections) {
if (conn.type === "direct") {
out.push({
connection: conn,
connectionId: conn.id,
url: buildDaemonWebSocketUrl(conn.endpoint),
activeConnection: {
type: "direct",
endpoint: conn.endpoint,
display: conn.endpoint,
},
});
continue;
}
for (const conn of relay) {
out.push({
connection: conn,
connectionId: conn.id,
url: buildRelayWebSocketUrl({
endpoint: conn.relayEndpoint,
@@ -67,123 +73,79 @@ function buildCandidates(host: HostProfile): Candidate[] {
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateHost } = useDaemonRegistry();
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const active = candidates[activeIndex] ?? candidates[0] ?? null;
const latencyQueries = useQueries({
queries: candidates.map((candidate) => ({
queryKey: ["connection-selection-latency", daemon.serverId, candidate.connectionId],
queryFn: () =>
measureConnectionLatency(candidate.connection, {
serverId: daemon.serverId,
}),
refetchInterval: 10_000,
staleTime: 9_000,
gcTime: 60_000,
retry: 1,
})),
});
const probeByConnectionId = useMemo(() => {
const next = new Map<string, ConnectionProbeState>();
candidates.forEach((candidate, index) => {
const query = latencyQueries[index];
if (!query) {
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
return;
}
if (query.isSuccess && typeof query.data === "number") {
next.set(candidate.connectionId, {
status: "available",
latencyMs: query.data,
});
return;
}
if (query.isError) {
next.set(candidate.connectionId, { status: "unavailable", latencyMs: null });
return;
}
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
});
return next;
}, [candidates, latencyQueries]);
const candidateInputs = useMemo<ConnectionCandidate[]>(
() =>
candidates.map((candidate) => ({
connectionId: candidate.connectionId,
connection: candidate.connection,
})),
[candidates]
);
const activeConnectionId = useMemo(
() =>
selectBestConnection({
candidates: candidateInputs,
preferredConnectionId: daemon.preferredConnectionId,
probeByConnectionId,
}),
[candidateInputs, daemon.preferredConnectionId, probeByConnectionId]
);
const active =
candidates.find((candidate) => candidate.connectionId === activeConnectionId) ?? null;
const activeUrl = active?.url ?? null;
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingPreferenceWriteRef = useRef(false);
const upgradeProbeInFlightRef = useRef(false);
useEffect(() => {
if (!activeUrl) {
return;
}
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.findIndex((c) => c.url === activeUrl);
if (idx === -1) {
setActiveIndex(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.map((c) => c.url).join("|")]);
if (!activeUrl) {
return null;
}
const connection = connectionStates.get(daemon.serverId);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (!active) return;
if (pendingPreferenceWriteRef.current) return;
if (daemon.preferredConnectionId === active.connectionId) return;
pendingPreferenceWriteRef.current = true;
void updateHost(daemon.serverId, {
preferredConnectionId: active.connectionId,
}).finally(() => {
pendingPreferenceWriteRef.current = false;
});
return;
}
if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) {
if (lastAttemptedUrlRef.current === activeUrl) {
return;
}
lastAttemptedUrlRef.current = activeUrl;
if (activeIndex < candidates.length - 1) {
setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1));
}
}
}, [
active,
activeIndex,
activeUrl,
candidates.length,
daemon.preferredConnectionId,
daemon.serverId,
lastError,
status,
updateHost,
connection,
]);
useEffect(() => {
if (!connection) return;
if (status !== "online") return;
if (activeIndex === 0) return;
const best = candidates[0];
if (!best) return;
if (best.activeConnection.type !== "direct") return;
const intervalMs = 15_000;
let cancelled = false;
const attemptUpgrade = async () => {
if (cancelled) return;
if (upgradeProbeInFlightRef.current) return;
if (activeIndex === 0) return;
upgradeProbeInFlightRef.current = true;
try {
const { serverId } = await probeConnection(
{ id: "probe", type: "direct", endpoint: best.activeConnection.endpoint },
{ timeoutMs: 2000 },
);
if (cancelled) return;
if (serverId !== daemon.serverId) return;
lastAttemptedUrlRef.current = null;
setActiveIndex(0);
} catch {
// ignore - we'll retry periodically
} finally {
upgradeProbeInFlightRef.current = false;
}
};
void attemptUpgrade();
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [activeIndex, candidates, connection, daemon.serverId, status]);
return (
<SessionProvider
key={`${daemon.serverId}:${activeUrl}`}
key={daemon.serverId}
serverUrl={activeUrl}
serverId={daemon.serverId}
activeConnection={active?.activeConnection ?? null}

View File

@@ -31,6 +31,11 @@ import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
import {
useSectionOrderStore,
} from "@/stores/section-order-store";
@@ -63,7 +68,7 @@ interface SectionHeaderProps {
checkout: SidebarCheckoutLite | null;
isCollapsed: boolean;
onToggle: () => void;
onCreateAgent: (workingDir: string) => void;
onCreateAgent: (serverId: string, workingDir: string) => void;
onDrag: () => void;
isDragging: boolean;
}
@@ -109,11 +114,12 @@ function SectionHeader({
const handleCreatePress = useCallback(
(e: { stopPropagation: () => void }) => {
e.stopPropagation();
if (createAgentWorkingDir) {
onCreateAgent(createAgentWorkingDir);
const serverId = section.firstAgentServerId;
if (createAgentWorkingDir && serverId) {
onCreateAgent(serverId, createAgentWorkingDir);
}
},
[onCreateAgent, createAgentWorkingDir]
[onCreateAgent, createAgentWorkingDir, section.firstAgentServerId]
);
return (
@@ -405,12 +411,12 @@ export function SidebarAgentList({
params: { serverId, agentId },
});
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(`/agent/${serverId}/${agentId}` as any);
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
@@ -432,9 +438,9 @@ export function SidebarAgentList({
}, [actionAgent, actionClient]);
const handleCreateAgentInProject = useCallback(
(workingDir: string) => {
(serverId: string, workingDir: string) => {
onAgentSelect?.();
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
router.push(buildNewAgentRoute(serverId, workingDir) as any);
},
[onAgentSelect]
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, useEffect } from "react";
import { useCallback, useMemo, useState, useEffect, useRef } from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
@@ -10,7 +10,7 @@ import Animated, {
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Plus, Settings, Users } from "lucide-react-native";
import { router } from "expo-router";
import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -20,6 +20,17 @@ import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-wind
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import {
buildHostAgentDraftRoute,
buildHostAgentsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
const DESKTOP_SIDEBAR_WIDTH = 320;
@@ -35,6 +46,42 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const activeServerIdFromPath = useMemo(
() => parseServerIdFromPathname(pathname),
[pathname]
);
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeServerId) return "No host";
const daemon = daemons.find((entry) => entry.serverId === activeServerId);
const trimmed = daemon?.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
}, [activeServerId, daemons]);
const activeHostStatus = activeServerId
? (connectionStates.get(activeServerId)?.status ?? "idle")
: "idle";
const activeHostStatusColor =
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
connectionStates.get(daemon.serverId)?.status ?? "idle"
),
})),
[connectionStates, daemons]
);
const hostTriggerRef = useRef<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
@@ -45,7 +92,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen });
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
const {
translateX,
backdropOpacity,
@@ -88,8 +135,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent]);
const handleCreateAgentClean = useCallback(() => {
router.push("/agent" as any);
}, []);
if (!activeServerId) {
return;
}
router.push(buildHostAgentDraftRoute(activeServerId) as any);
}, [activeServerId]);
// Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => {
@@ -104,14 +154,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return;
}
closeToAgent();
router.push("/settings");
}, [closeToAgent]);
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
// Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => {
router.push("/settings");
}, []);
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
// Mobile: close sidebar when agent is selected
// Snap immediately since navigation interrupts animations
@@ -122,13 +178,35 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
}
if (isMobile) {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
}
router.push("/agents");
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
router.push(buildHostAgentsRoute(activeServerId) as any);
}, [
activeServerId,
backdropOpacity,
closeToAgent,
isMobile,
translateX,
windowWidth,
]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
);
// Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
@@ -206,18 +284,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanMobile}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanMobile}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
@@ -286,18 +392,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
{...dragHandlers}
>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanDesktop}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanDesktop}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
@@ -383,12 +517,19 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
flexShrink: 0,
},
newAgentButtonHovered: {},
newAgentButtonText: {
@@ -399,6 +540,25 @@ const styles = StyleSheet.create((theme) => ({
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[2],
minWidth: 0,
maxWidth: "55%",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,405 @@
"use dom";
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
outputText: string;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
onInput?: (data: string) => Promise<void> | void;
onResize?: (rows: number, cols: number) => Promise<void> | void;
}
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
}
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log(`[TerminalDebug][DOM] ${message}`, payload);
return;
}
console.log(`[TerminalDebug][DOM] ${message}`);
}
export default function TerminalEmulator({
streamKey,
outputText,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
onInput,
onResize,
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const renderedOutputRef = useRef("");
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
useEffect(() => {
onInputRef.current = onInput;
}, [onInput]);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
const host = hostRef.current;
const root = rootRef.current;
if (!host || !root) {
return;
}
logTerminalDebug("mount", {
streamKey,
hasOnInput: Boolean(onInputRef.current),
hasOnResize: Boolean(onResizeRef.current),
});
renderedOutputRef.current = "";
lastSizeRef.current = null;
host.innerHTML = "";
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 10_000,
theme: {
background: backgroundColor,
foreground: foregroundColor,
cursor: cursorColor,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(host);
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = root.parentElement;
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
const previousRootHeight = rootContainer?.style.height ?? "";
// Force document to follow WebView bounds; xterm viewport owns scrollback.
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
if (rootContainer) {
rootContainer.style.overflow = "hidden";
rootContainer.style.width = "100%";
rootContainer.style.height = "100%";
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "contain";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
terminalRef.current = terminal;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force = false) => {
const handler = onResizeRef.current;
if (!handler) {
return;
}
try {
fitAddon.fit();
} catch {
logTerminalDebug("fit failed");
return;
}
const rows = terminal.rows;
const cols = terminal.cols;
const previous = lastSizeRef.current;
if (!force && previous && previous.rows === rows && previous.cols === cols) {
return;
}
lastSizeRef.current = { rows, cols };
const rootRect = root.getBoundingClientRect();
logTerminalDebug("fit+resize", {
force,
rows,
cols,
rootWidth: Math.round(rootRect.width),
rootHeight: Math.round(rootRect.height),
});
void handler(rows, cols);
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
const handler = onInputRef.current;
if (!handler) {
return;
}
logTerminalDebug("input", {
length: data.length,
preview: data.slice(0, 20),
});
void handler(data);
});
let lastScrollLogTs = 0;
let lastWheelLogTs = 0;
let lastTouchMoveLogTs = 0;
const viewportScrollHandler = () => {
const now = Date.now();
if (now - lastScrollLogTs < 120) {
return;
}
lastScrollLogTs = now;
logTerminalDebug("viewport scroll", {
baseY: terminal.buffer.active.baseY,
viewportY: terminal.buffer.active.viewportY,
});
};
const viewportWheelHandler = (event: WheelEvent) => {
const now = Date.now();
if (now - lastWheelLogTs < 120) {
return;
}
lastWheelLogTs = now;
logTerminalDebug("viewport wheel", {
deltaY: event.deltaY,
deltaX: event.deltaX,
});
};
const viewportTouchStartHandler = (event: TouchEvent) => {
logTerminalDebug("viewport touchstart", {
touches: event.touches.length,
});
};
const viewportTouchMoveHandler = (event: TouchEvent) => {
const now = Date.now();
if (now - lastTouchMoveLogTs < 120) {
return;
}
lastTouchMoveLogTs = now;
logTerminalDebug("viewport touchmove", {
touches: event.touches.length,
});
};
viewportElement?.addEventListener("scroll", viewportScrollHandler, { passive: true });
viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true });
viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, {
passive: true,
});
viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, {
passive: true,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize();
});
resizeObserver.observe(root);
const windowResizeHandler = () => fitAndEmitResize();
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize();
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
// Safety net for keyboard/layout transitions that can skip callbacks.
const fitInterval = window.setInterval(() => {
fitAndEmitResize();
}, 250);
window.setTimeout(() => fitAndEmitResize(true), 0);
if (outputText.length > 0) {
terminal.write(outputText);
renderedOutputRef.current = outputText;
}
terminal.focus();
return () => {
inputDisposable.dispose();
resizeObserver.disconnect();
window.removeEventListener("resize", windowResizeHandler);
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
window.clearInterval(fitInterval);
viewportElement?.removeEventListener("scroll", viewportScrollHandler);
viewportElement?.removeEventListener("wheel", viewportWheelHandler);
viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler);
viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler);
fitAddon.dispose();
terminal.dispose();
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
rootContainer.style.width = previousRootWidth;
rootContainer.style.height = previousRootHeight;
}
if (viewportElement) {
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
viewportElement.style.touchAction = previousViewportTouchAction;
viewportElement.style.overflowY = previousViewportOverflowY;
viewportElement.style.overflowX = previousViewportOverflowX;
viewportElement.style.pointerEvents = previousViewportPointerEvents;
viewportElement.style.setProperty(
"-webkit-overflow-scrolling",
previousViewportWebkitOverflowScrolling
);
}
terminalRef.current = null;
if (window.__paseoTerminal === terminal) {
window.__paseoTerminal = undefined;
}
logTerminalDebug("unmount", { streamKey });
renderedOutputRef.current = "";
lastSizeRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
const previous = renderedOutputRef.current;
if (outputText === previous) {
return;
}
if (previous.length > 0 && outputText.startsWith(previous)) {
const suffix = outputText.slice(previous.length);
if (suffix.length > 0) {
terminal.write(suffix);
}
} else {
terminal.reset();
terminal.clear();
if (outputText.length > 0) {
terminal.write(outputText);
}
}
renderedOutputRef.current = outputText;
}, [outputText]);
return (
<div
ref={rootRef}
data-testid={testId}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
display: "flex",
minHeight: 0,
minWidth: 0,
backgroundColor,
overflow: "hidden",
overscrollBehavior: "none",
}}
onPointerDown={() => {
logTerminalDebug("root pointerdown", { streamKey });
terminalRef.current?.focus();
}}
>
<div
ref={hostRef}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
width: "100%",
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,742 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActivityIndicator,
type LayoutChangeEvent,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { Plus, RefreshCw } from "lucide-react-native";
import { NativeViewGestureHandler } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
}
const MAX_OUTPUT_CHARS = 200_000;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
shift: "Shift",
alt: "Alt",
} as const;
const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [
{ id: "esc", label: "Esc", key: "Escape" },
{ id: "tab", label: "Tab", key: "Tab" },
{ id: "up", label: "↑", key: "ArrowUp" },
{ id: "down", label: "↓", key: "ArrowDown" },
{ id: "left", label: "←", key: "ArrowLeft" },
{ id: "right", label: "→", key: "ArrowRight" },
{ id: "enter", label: "Enter", key: "Enter" },
{ id: "backspace", label: "⌫", key: "Backspace" },
{ id: "c", label: "C", key: "c" },
];
type ModifierState = {
ctrl: boolean;
shift: boolean;
alt: boolean;
};
function terminalScopeKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd}`;
}
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log("[TerminalDebug][Pane] " + message, payload);
return;
}
console.log("[TerminalDebug][Pane] " + message);
}
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Optional when rendered inside the mobile explorer sidebar gesture context.
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Terminal pane can render outside explorer sidebar during isolated tests.
}
const queryClient = useQueryClient();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const scopeKey = useMemo(() => terminalScopeKey(serverId, cwd), [serverId, cwd]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
() => new Map()
);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
} | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>({
ctrl: false,
shift: false,
alt: false,
});
const terminalsQuery = useQuery({
queryKey: ["terminals", serverId, cwd] as const,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.listTerminals(cwd);
},
staleTime: 5_000,
});
const terminals = terminalsQuery.data?.terminals ?? [];
const createTerminalMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
if (payload.terminal) {
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
setSelectedTerminalId(payload.terminal.id);
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
});
},
});
useEffect(() => {
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
lastReportedSizeRef.current = null;
}, [scopeKey]);
useEffect(() => {
if (selectedTerminalId) {
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
}
}, [scopeKey, selectedTerminalId]);
useEffect(() => {
if (terminals.length === 0) {
setSelectedTerminalId(null);
return;
}
const has = (id: string | null | undefined) =>
Boolean(id && terminals.some((terminal) => terminal.id === id));
if (has(selectedTerminalId)) {
return;
}
const stored = selectedTerminalByScopeRef.current.get(scopeKey);
if (has(stored)) {
setSelectedTerminalId(stored!);
return;
}
const fallback = terminals[0]?.id ?? null;
if (fallback) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
setSelectedTerminalId(fallback);
}
}, [scopeKey, terminals, selectedTerminalId]);
const appendOutput = useCallback((terminalId: string, text: string) => {
if (!text) {
return;
}
setOutputByTerminalId((previous) => {
const next = new Map(previous);
const existing = next.get(terminalId) ?? "";
const combined = `${existing}${text}`;
next.set(
terminalId,
combined.length > MAX_OUTPUT_CHARS
? combined.slice(combined.length - MAX_OUTPUT_CHARS)
: combined
);
return next;
});
}, []);
useEffect(() => {
let isCancelled = false;
let streamId: number | null = null;
let unsubscribe: (() => void) | null = null;
let decoder: TextDecoder | null = null;
const terminalId = selectedTerminalId;
if (!client || !isConnected || !terminalId) {
setActiveStream(null);
setIsAttaching(false);
return;
}
setIsAttaching(true);
setStreamError(null);
lastReportedSizeRef.current = null;
const attach = async () => {
try {
const attachPayload = await client.attachTerminalStream(terminalId);
if (isCancelled) {
if (typeof attachPayload.streamId === "number") {
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
}
return;
}
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
setStreamError(attachPayload.error ?? "Unable to attach terminal stream");
setActiveStream(null);
return;
}
streamId = attachPayload.streamId;
decoder = new TextDecoder();
setActiveStream({ terminalId, streamId });
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
if (isCancelled) {
return;
}
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
appendOutput(terminalId, text);
});
} catch (error) {
if (!isCancelled) {
setStreamError(
error instanceof Error ? error.message : "Unable to attach terminal stream"
);
setActiveStream(null);
}
} finally {
if (!isCancelled) {
setIsAttaching(false);
}
}
};
void attach();
return () => {
isCancelled = true;
if (decoder) {
appendOutput(terminalId, decoder.decode());
decoder = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
if (streamId !== null) {
void client.detachTerminalStream(streamId).catch(() => {});
}
};
}, [appendOutput, client, isConnected, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId]
);
const waitForCloseGesture = Boolean(isMobile && closeGestureRef?.current);
useEffect(() => {
logTerminalDebug("render state", {
scopeKey,
isMobile,
terminals: terminals.length,
selectedTerminalId,
activeStreamId,
isAttaching,
waitForCloseGesture,
});
}, [
activeStreamId,
isAttaching,
isMobile,
scopeKey,
selectedTerminalId,
terminals.length,
waitForCloseGesture,
]);
const handleOutputLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
logTerminalDebug("output layout", {
width: Math.round(width),
height: Math.round(height),
selectedTerminalId,
});
},
[selectedTerminalId]
);
const handleTerminalLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
logTerminalDebug("terminal container layout", {
width: Math.round(width),
height: Math.round(height),
});
}, []);
const currentOutput = selectedTerminalId
? (outputByTerminalId.get(selectedTerminalId) ?? "")
: "";
const handleRefresh = useCallback(() => {
void terminalsQuery.refetch();
}, [terminalsQuery]);
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
const handleTerminalData = useCallback(
async (data: string) => {
if (!client || activeStreamId === null || data.length === 0) {
return;
}
logTerminalDebug("send input", {
length: data.length,
preview: data.slice(0, 24),
});
client.sendTerminalStreamInput(activeStreamId, data);
},
[client, activeStreamId]
);
const handleTerminalResize = useCallback(
async (rows: number, cols: number) => {
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current;
if (
previous &&
previous.rows === normalizedRows &&
previous.cols === normalizedCols
) {
return;
}
logTerminalDebug("send resize", {
rows: normalizedRows,
cols: normalizedCols,
selectedTerminalId,
});
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
[client, selectedTerminalId]
);
const toggleModifier = useCallback((modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
}, []);
const sendVirtualKey = useCallback(
(key: string) => {
if (!client || activeStreamId === null) {
return;
}
client.sendTerminalStreamKey(activeStreamId, {
key: key.length === 1 ? key.toLowerCase() : key,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
});
logTerminalDebug("send virtual key", {
key,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
});
setModifiers({ ctrl: false, shift: false, alt: false });
},
[client, activeStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift]
);
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
<Text style={styles.stateText}>Host is not connected</Text>
</View>
);
}
const queryError =
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending;
const isRefreshing = terminalsQuery.isFetching;
const createError =
createTerminalMutation.error instanceof Error
? createTerminalMutation.error.message
: null;
const combinedError = streamError ?? createError ?? queryError;
return (
<View style={styles.container}>
<View style={styles.header} testID="terminals-header">
<ScrollView
horizontal
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{terminals.map((terminal) => {
const isActive = terminal.id === selectedTerminalId;
return (
<Pressable
key={terminal.id}
testID={`terminal-tab-${terminal.id}`}
onPress={() => setSelectedTerminalId(terminal.id)}
style={({ pressed, hovered }) => [
styles.terminalTab,
isActive && styles.terminalTabActive,
(pressed || hovered) && styles.terminalTabHovered,
]}
>
<Text style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}>
{terminal.name}
</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.headerActions}>
<Pressable
testID="terminals-refresh-button"
onPress={handleRefresh}
disabled={isRefreshing}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isRefreshing ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<RefreshCw size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<Pressable
testID="terminals-create-button"
onPress={handleCreateTerminal}
disabled={isCreating}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isCreating ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Plus size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
<View style={styles.outputContainer} onLayout={handleOutputLayout}>
{selectedTerminal ? (
<NativeViewGestureHandler
disallowInterruption={false}
waitFor={waitForCloseGesture ? closeGestureRef : undefined}
>
<View style={styles.terminalGestureContainer} onLayout={handleTerminalLayout}>
<TerminalEmulator
dom={{
style: { flex: 1 },
matchContents: false,
scrollEnabled: true,
nestedScrollEnabled: true,
overScrollMode: "never",
}}
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
outputText={currentOutput}
testId="terminal-surface"
backgroundColor={theme.colors.background}
foregroundColor={theme.colors.foreground}
cursorColor={theme.colors.foreground}
onInput={handleTerminalData}
onResize={handleTerminalResize}
/>
</View>
</NativeViewGestureHandler>
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching ? (
<View style={styles.attachOverlay}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.attachOverlayText}>Attaching terminal</Text>
</View>
) : null}
</View>
{combinedError ? (
<View style={styles.errorRow}>
<Text style={styles.statusError} numberOfLines={2}>
{combinedError}
</Text>
</View>
) : null}
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.keyboardRow}>
{(Object.keys(MODIFIER_LABELS) as Array<keyof ModifierState>).map((modifier) => (
<Pressable
key={modifier}
testID={`terminal-key-${modifier}`}
onPress={() => toggleModifier(modifier)}
style={({ hovered, pressed }) => [
styles.keyButton,
modifiers[modifier] && styles.keyButtonActive,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
))}
{KEY_BUTTONS.map((button) => (
<Pressable
key={button.id}
testID={`terminal-key-${button.id}`}
onPress={() => sendVirtualKey(button.key)}
style={({ hovered, pressed }) => [
styles.keyButton,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={styles.keyButtonText}>{button.label}</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
header: {
minHeight: 48,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
tabsScroll: {
flex: 1,
minWidth: 0,
},
tabsContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
},
terminalTab: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
terminalTabHovered: {
backgroundColor: theme.colors.surface2,
},
terminalTabActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surface2,
},
terminalTabText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
terminalTabTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
headerIconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
headerIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
outputContainer: {
flex: 1,
minHeight: 0,
position: "relative",
backgroundColor: theme.colors.background,
},
terminalGestureContainer: {
flex: 1,
minHeight: 0,
},
attachOverlay: {
position: "absolute",
top: theme.spacing[3],
right: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
attachOverlayText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
statusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
keyboardContainer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
keyboardRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[3],
},
keyButton: {
minWidth: 44,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
keyButtonHovered: {
backgroundColor: theme.colors.surface2,
},
keyButtonActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surface2,
},
keyButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
keyButtonTextActive: {
color: theme.colors.foreground,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
},
stateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));

View File

@@ -35,6 +35,7 @@ export interface ComboboxProps {
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
onSearchQueryChange?: (query: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
@@ -156,6 +157,7 @@ export function Combobox({
options,
value,
onSelect,
onSearchQueryChange,
placeholder = "Search...",
searchPlaceholder,
emptyText = "No options match your search.",
@@ -191,16 +193,24 @@ export function Combobox({
[isControlled, onOpenChange]
);
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
},
[onSearchQueryChange]
);
const handleClose = useCallback(() => {
setOpen(false);
setSearchQuery("");
}, [setOpen]);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
setSearchQueryWithCallback("");
}
}, [isOpen]);
}, [isOpen, setSearchQueryWithCallback]);
const collisionPadding = useMemo(() => {
const basePadding = 16;
@@ -426,7 +436,7 @@ export function Combobox({
<SearchInput
placeholder={searchPlaceholder ?? placeholder}
value={searchQuery}
onChangeText={setSearchQuery}
onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>

View File

@@ -9,6 +9,7 @@ import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { NameHostModal } from "./name-host-modal";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -90,7 +91,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace({ pathname: "/", params: { serverId } });
router.replace(buildHostAgentDraftRoute(serverId) as any);
},
[router]
);

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
hostHasDirectEndpoint,
registryHasDirectEndpoint,
type HostProfile,
} from './daemon-registry-context'
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): HostProfile {
const now = '2026-01-01T00:00:00.000Z'
return {
serverId: input.serverId,
label: input.label ?? input.serverId,
connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
}
}
describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => {
const host = makeHost({
serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
})
it('returns false when only relay connections exist', () => {
const host = makeHost({
serverId: 'srv_relay',
connections: [
{
id: 'relay:relay.example:443',
type: 'relay',
relayEndpoint: 'relay.example:443',
daemonPublicKeyB64: 'abcd',
},
],
preferredConnectionId: 'relay:relay.example:443',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
})
})
describe('registryHasDirectEndpoint', () => {
it('returns true when any host contains the direct endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
makeHost({
serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
})
it('returns false when no host has the endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
})
})

View File

@@ -1,97 +1,131 @@
import { createContext, useCallback, useContext } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
decodeOfferFragmentPayload,
normalizeHostPort,
} from "@/utils/daemon-endpoints";
import {
ConnectionOfferSchema,
type ConnectionOffer,
} from "@server/shared/connection-offer";
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectHostConnection = {
id: string;
type: "direct";
endpoint: string; // host:port
};
export type RelayHostConnection = {
id: string;
type: "relay";
relayEndpoint: string; // host:port
daemonPublicKeyB64: string;
};
export type HostConnection = DirectHostConnection | RelayHostConnection;
export type HostProfile = {
serverId: string;
label: string;
connections: HostConnection[];
preferredConnectionId: string | null;
createdAt: string;
updatedAt: string;
};
export type UpdateHostInput = Partial<Omit<HostProfile, "serverId" | "createdAt">>;
interface DaemonRegistryContextValue {
daemons: HostProfile[];
isLoading: boolean;
error: unknown | null;
upsertDirectConnection: (input: {
serverId: string;
endpoint: string;
label?: string;
}) => Promise<HostProfile>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
id: string
type: 'direct'
endpoint: string // host:port
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string // host:port
daemonPublicKeyB64: string
}
export type HostConnection = DirectHostConnection | RelayHostConnection
export type HostProfile = {
serverId: string
label: string
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
updatedAt: string
}
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
interface DaemonRegistryContextValue {
daemons: HostProfile[]
isLoading: boolean
error: unknown | null
upsertDirectConnection: (input: {
serverId: string
endpoint: string
label?: string
}) => Promise<HostProfile>
upsertRelayConnection: (input: {
serverId: string
relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => Promise<HostProfile>
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
removeHost: (serverId: string) => Promise<void>
removeConnection: (serverId: string, connectionId: string) => Promise<void>
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
} catch {
return null
}
}
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
const normalized = normalizeEndpointOrNull(endpoint)
if (!normalized) {
return false
}
return host.connections.some(
(connection) => connection.type === 'direct' && connection.endpoint === normalized
)
}
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
}
export function useDaemonRegistry(): DaemonRegistryContextValue {
const ctx = useContext(DaemonRegistryContext);
const ctx = useContext(DaemonRegistryContext)
if (!ctx) {
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider");
throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
}
return ctx;
return ctx
}
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: daemons = [], isPending, error } = useQuery({
const queryClient = useQueryClient()
const localhostBootstrapAttemptedRef = useRef(false)
const {
data: daemons = [],
isPending,
error,
} = useQuery({
queryKey: DAEMON_REGISTRY_QUERY_KEY,
queryFn: loadDaemonRegistryFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
})
const persist = useCallback(
async (profiles: HostProfile[]) => {
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
},
[queryClient]
);
)
const readDaemons = useCallback(() => {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
}, [queryClient, daemons]);
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons])
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
}, [])
const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => {
@@ -103,68 +137,78 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
updatedAt: new Date().toISOString(),
}
: daemon
);
await persist(next);
)
await persist(next)
},
[persist, readDaemons]
);
)
const removeHost = useCallback(
async (serverId: string) => {
const remaining = readDaemons().filter((daemon) => daemon.serverId !== serverId);
await persist(remaining);
const existing = readDaemons()
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining)
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[persist, readDaemons]
);
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
)
const removeConnection = useCallback(
async (serverId: string, connectionId: string) => {
const now = new Date().toISOString();
const next = readDaemons()
const existing = readDaemons()
const removedConnection =
existing
.find((daemon) => daemon.serverId === serverId)
?.connections.find((connection) => connection.id === connectionId) ?? null
const now = new Date().toISOString()
const next = existing
.map((daemon) => {
if (daemon.serverId !== serverId) return daemon;
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId);
if (remaining.length === 0) {
return null;
}
const preferred =
daemon.preferredConnectionId === connectionId
? remaining[0]?.id ?? null
: daemon.preferredConnectionId;
return {
...daemon,
connections: remaining,
preferredConnectionId: preferred,
updatedAt: now,
} satisfies HostProfile;
})
.filter((entry): entry is HostProfile => entry !== null);
await persist(next);
if (daemon.serverId !== serverId) return daemon
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
if (remaining.length === 0) {
return null
}
const preferred =
daemon.preferredConnectionId === connectionId
? (remaining[0]?.id ?? null)
: daemon.preferredConnectionId
return {
...daemon,
connections: remaining,
preferredConnectionId: preferred,
updatedAt: now,
} satisfies HostProfile
})
.filter((entry): entry is HostProfile => entry !== null)
await persist(next)
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[persist, readDaemons]
);
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
)
const upsertHostConnection = useCallback(
async (
input: {
serverId: string;
label?: string;
} & (
| { connection: DirectHostConnection }
| { connection: RelayHostConnection }
)
serverId: string
label?: string
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
) => {
const existing = readDaemons();
const now = new Date().toISOString();
const serverId = input.serverId.trim();
const existing = readDaemons()
const now = new Date().toISOString()
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error("serverId is required");
throw new Error('serverId is required')
}
const labelTrimmed = input.label?.trim() ?? "";
const derivedLabel = labelTrimmed || serverId;
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const idx = existing.findIndex((d) => d.serverId === serverId);
const idx = existing.findIndex((d) => d.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
@@ -173,18 +217,18 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
};
const next = [...existing, profile];
await persist(next);
return profile;
}
const next = [...existing, profile]
await persist(next)
return profile
}
const prev = existing[idx]!;
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id);
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c));
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
const nextProfile: HostProfile = {
...prev,
@@ -192,54 +236,118 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
connections: nextConnections,
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
updatedAt: now,
};
}
const next = [...existing];
next[idx] = nextProfile;
await persist(next);
return nextProfile;
const next = [...existing]
next[idx] = nextProfile
await persist(next)
return nextProfile
},
[persist, readDaemons]
);
)
const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint);
const endpoint = normalizeHostPort(input.endpoint)
const connection: DirectHostConnection = {
id: `direct:${endpoint}`,
type: "direct",
type: 'direct',
endpoint,
};
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
});
})
},
[upsertHostConnection]
);
)
useEffect(() => {
if (isPending) return
if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true
let cancelled = false
const bootstrapDefaultLocalhost = async () => {
try {
const [isE2E, alreadyHandled] = await Promise.all([
AsyncStorage.getItem(E2E_STORAGE_KEY),
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
])
if (cancelled || isE2E || alreadyHandled) {
return
}
const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
return
}
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'direct',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (cancelled) return
await upsertDirectConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
})
await markDefaultLocalhostBootstrapHandled()
} catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
}
} catch (bootstrapError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to bootstrap default localhost connection',
bootstrapError
)
}
}
void bootstrapDefaultLocalhost()
return () => {
cancelled = true
}
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
const upsertRelayConnection = useCallback(
async (input: { serverId: string; relayEndpoint: string; daemonPublicKeyB64: string; label?: string }) => {
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
async (input: {
serverId: string
relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => {
const relayEndpoint = normalizeHostPort(input.relayEndpoint)
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required");
throw new Error('daemonPublicKeyB64 is required')
}
const connection: RelayHostConnection = {
id: `relay:${relayEndpoint}`,
type: "relay",
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
};
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
});
})
},
[upsertHostConnection]
);
)
const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOffer) => {
@@ -247,28 +355,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
});
})
},
[upsertRelayConnection]
);
)
const upsertDaemonFromOfferUrl = useCallback(
async (offerUrlOrFragment: string) => {
const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker);
const marker = '#offer='
const idx = offerUrlOrFragment.indexOf(marker)
if (idx === -1) {
throw new Error("Missing #offer= fragment");
throw new Error('Missing #offer= fragment')
}
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
if (!encoded) {
throw new Error("Offer payload is empty");
throw new Error('Offer payload is empty')
}
const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload);
return upsertDaemonFromOffer(offer);
const payload = decodeOfferFragmentPayload(encoded)
const offer = ConnectionOfferSchema.parse(payload)
return upsertDaemonFromOffer(offer)
},
[upsertDaemonFromOffer]
);
)
const value: DaemonRegistryContextValue = {
daemons,
@@ -281,69 +389,65 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
removeConnection,
upsertDaemonFromOffer,
upsertDaemonFromOfferUrl,
};
}
return (
<DaemonRegistryContext.Provider value={value}>
{children}
</DaemonRegistryContext.Provider>
);
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
}
type LegacyHostProfileV1 = {
id: string;
label: string;
endpoints?: unknown;
daemonPublicKeyB64?: unknown;
relay?: unknown;
createdAt: string;
updatedAt: string;
};
id: string
label: string
endpoints?: unknown
daemonPublicKeyB64?: unknown
relay?: unknown
createdAt: string
updatedAt: string
}
function isHostProfileV2(value: unknown): value is HostProfile {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>
return (
typeof obj.serverId === "string" &&
typeof obj.label === "string" &&
typeof obj.serverId === 'string' &&
typeof obj.label === 'string' &&
Array.isArray(obj.connections) &&
typeof obj.createdAt === "string" &&
typeof obj.updatedAt === "string"
);
typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === 'string'
)
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as unknown;
const parsed = JSON.parse(stored) as unknown
if (Array.isArray(parsed)) {
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[];
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
if (v2.length === parsed.length) {
return v2;
return v2
}
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
const migrated: HostProfile[] = parsed
.map((entry): HostProfile | null => {
if (!entry || typeof entry !== "object") return null;
const obj = entry as LegacyHostProfileV1;
if (typeof obj.id !== "string" || typeof obj.label !== "string") return null;
if (!entry || typeof entry !== 'object') return null
const obj = entry as LegacyHostProfileV1
if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
if (!obj.id.startsWith("srv_")) return null;
if (!obj.id.startsWith('srv_')) return null
const now = new Date().toISOString();
const createdAt = typeof obj.createdAt === "string" ? obj.createdAt : now;
const updatedAt = typeof obj.updatedAt === "string" ? obj.updatedAt : now;
const now = new Date().toISOString()
const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
const connections: HostConnection[] = [];
const connections: HostConnection[] = []
if (Array.isArray(obj.endpoints)) {
for (const endpointRaw of obj.endpoints) {
try {
const endpoint = normalizeHostPort(String(endpointRaw));
connections.push({ id: `direct:${endpoint}`, type: "direct", endpoint });
const endpoint = normalizeHostPort(String(endpointRaw))
connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
} catch {
// ignore invalid endpoint
}
@@ -351,29 +455,29 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
}
const relayEndpointRaw =
obj.relay && typeof (obj.relay as any)?.endpoint === "string"
obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
? String((obj.relay as any).endpoint)
: null;
: null
const daemonPublicKeyB64 =
typeof obj.daemonPublicKeyB64 === "string" ? obj.daemonPublicKeyB64.trim() : "";
typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
if (relayEndpointRaw && daemonPublicKeyB64) {
try {
const relayEndpoint = normalizeHostPort(relayEndpointRaw);
const relayEndpoint = normalizeHostPort(relayEndpointRaw)
connections.push({
id: `relay:${relayEndpoint}`,
type: "relay",
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
});
})
} catch {
// ignore invalid relay endpoint
}
}
if (connections.length === 0) return null;
if (connections.length === 0) return null
const preferredConnectionId: string | null = connections[0]?.id ?? null;
const preferredConnectionId: string | null = connections[0]?.id ?? null
return {
serverId: obj.id,
@@ -382,18 +486,18 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
preferredConnectionId,
createdAt,
updatedAt,
};
}
})
.filter((entry): entry is HostProfile => entry !== null);
.filter((entry): entry is HostProfile => entry !== null)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
return migrated;
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
return migrated
}
}
return [];
return []
} catch (error) {
console.error("[DaemonRegistry] Failed to load daemon registry", error);
throw error;
console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error
}
}

View File

@@ -9,6 +9,7 @@ import {
applyStreamEvent,
generateMessageId,
hydrateStreamState,
reduceStreamUpdate,
type StreamItem,
} from "@/types/stream";
import type {
@@ -17,6 +18,7 @@ import type {
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
@@ -303,6 +305,9 @@ export function SessionProvider({
const clearAgentStreamHead = useSessionStore(
(state) => state.clearAgentStreamHead
);
const setAgentTimelineCursor = useSessionStore(
(state) => state.setAgentTimelineCursor
);
const setInitializingAgents = useSessionStore(
(state) => state.setInitializingAgents
);
@@ -371,7 +376,7 @@ export function SessionProvider({
) => Promise<void>)
| null
>(null);
const hasRequestedInitialSnapshotRef = useRef(false);
const hasBootstrappedAgentUpdatesRef = useRef(false);
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
@@ -580,6 +585,15 @@ export function SessionProvider({
return next;
});
setAgentTimelineCursor(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
return;
}
@@ -644,6 +658,7 @@ export function SessionProvider({
setAgentLastActivity,
setPendingPermissions,
setQueuedMessages,
setAgentTimelineCursor,
]
);
@@ -665,18 +680,6 @@ export function SessionProvider({
[serverId, setFileExplorer]
);
const refreshAgentMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
if (!agentId) {
throw new Error("Agent id is required");
}
if (!client) {
throw new Error("Daemon client unavailable");
}
return await client.refreshAgent(agentId);
},
});
const directoryListingMutation = useMutation({
mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => {
if (!agentId) {
@@ -740,9 +743,109 @@ export function SessionProvider({
},
});
const applyTimelineResponse = useCallback(
(
payload: Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"]
) => {
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
if (payload.error) {
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(initKey, new Error(payload.error));
return;
}
const hydratedEvents: Array<{
event: AgentStreamEventPayload;
timestamp: Date;
}> = payload.entries.map((entry) => ({
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
},
timestamp: new Date(entry.timestamp),
}));
const replace = payload.reset || payload.direction !== "after";
if (replace) {
const hydrated = hydrateStreamState(hydratedEvents);
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, hydrated);
return next;
});
clearAgentStreamHead(serverId, agentId);
} else if (hydratedEvents.length > 0) {
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(agentId) ?? [];
const updated = hydratedEvents.reduce<StreamItem[]>(
(state, { event, timestamp }) => reduceStreamUpdate(state, event, timestamp),
current
);
next.set(agentId, updated);
return next;
});
}
setAgentTimelineCursor(serverId, (prev) => {
const next = new Map(prev);
if (payload.startCursor && payload.endCursor) {
next.set(agentId, {
epoch: payload.epoch,
startSeq: payload.startCursor.seq,
endSeq: payload.endCursor.seq,
});
} else if (payload.reset) {
next.delete(agentId);
}
return next;
});
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
pendingAgentUpdatesRef.current.delete(agentId);
if (deferredUpdate) {
applyAgentUpdatePayload(deferredUpdate);
}
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
resolveInitDeferred(initKey);
markAgentHistorySynchronized(serverId, agentId);
},
[
applyAgentUpdatePayload,
clearAgentStreamHead,
markAgentHistorySynchronized,
serverId,
setAgentStreamTail,
setAgentTimelineCursor,
setInitializingAgents,
]
);
useEffect(() => {
if (!connectionSnapshot.isConnected) {
hasRequestedInitialSnapshotRef.current = false;
hasBootstrappedAgentUpdatesRef.current = false;
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
if (subscriptionId && client) {
try {
@@ -754,10 +857,10 @@ export function SessionProvider({
agentUpdatesSubscriptionIdRef.current = null;
return;
}
if (hasRequestedInitialSnapshotRef.current) {
if (hasBootstrappedAgentUpdatesRef.current) {
return;
}
hasRequestedInitialSnapshotRef.current = true;
hasBootstrappedAgentUpdatesRef.current = true;
try {
if (!agentUpdatesSubscriptionIdRef.current) {
@@ -803,7 +906,7 @@ export function SessionProvider({
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp } = message.payload;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
if (event.type === "attention_required") {
@@ -846,74 +949,53 @@ export function SessionProvider({
});
}
if (
event.type === "timeline" &&
typeof seq === "number" &&
typeof epoch === "string"
) {
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
const next = new Map(prev);
if (!current || current.epoch !== epoch) {
next.set(agentId, { epoch, startSeq: seq, endSeq: seq });
return next;
}
next.set(agentId, {
epoch,
startSeq: Math.min(current.startSeq, seq),
endSeq: Math.max(current.endSeq, seq),
});
return next;
});
}
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes.
});
const unsubAgentStreamSnapshot = client.on(
"agent_stream_snapshot",
const unsubAgentTimeline = client.on(
"fetch_agent_timeline_response",
(message) => {
if (message.type !== "agent_stream_snapshot") return;
const { agentId, events } = message.payload;
const hydrated = hydrateStreamState(
events.map(({ event, timestamp }) => ({
event: event as AgentStreamEventPayload,
timestamp: new Date(timestamp),
}))
);
const initKey = getInitKey(serverId, agentId);
const hasInFlightHistorySync = Boolean(getInitDeferred(initKey));
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, hydrated);
return next;
});
clearAgentStreamHead(serverId, agentId);
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
pendingAgentUpdatesRef.current.delete(agentId);
if (hasInFlightHistorySync && deferredUpdate) {
applyAgentUpdatePayload(deferredUpdate);
}
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
// Resolve the initialization promise (even for empty history)
resolveInitDeferred(initKey);
markAgentHistorySynchronized(serverId, agentId);
if (message.type !== "fetch_agent_timeline_response") return;
applyTimelineResponse(message.payload);
}
);
const unsubStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const status = message.payload.status;
if (status === "server_info") {
const payload = message.payload as any;
const rawServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
if (!rawServerId) return;
const rawHostname = typeof payload.hostname === "string" ? payload.hostname.trim() : "";
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
updateSessionServerInfo(serverId, {
serverId: rawServerId,
hostname: rawHostname.length > 0 ? rawHostname : null,
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities }
: {}),
});
return;
}
if (status === "agent_initialized" && "agentId" in message.payload) {
console.log("[Session] status agent_initialized", {
agentId: (message.payload as any).agentId,
requestId: (message.payload as any).requestId,
});
}
});
const unsubPermissionRequest = client.on(
@@ -1233,6 +1315,14 @@ export function SessionProvider({
return next;
});
clearAgentStreamHead(serverId, agentId);
setAgentTimelineCursor(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
// Remove draft input
clearDraftInput(agentId);
@@ -1292,7 +1382,7 @@ export function SessionProvider({
return () => {
unsubAgentUpdate();
unsubAgentStream();
unsubAgentStreamSnapshot();
unsubAgentTimeline();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -1313,6 +1403,7 @@ export function SessionProvider({
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentTimelineCursor,
setInitializingAgents,
setAgents,
setAgentLastActivity,
@@ -1323,63 +1414,9 @@ export function SessionProvider({
clearDraftInput,
notifyAgentAttention,
applyAgentUpdatePayload,
markAgentHistorySynchronized,
applyTimelineResponse,
]);
const initializeAgent = useCallback(
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
if (!client) {
console.warn("[Session] initializeAgent skipped: daemon unavailable");
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
return;
}
client
.initializeAgent(agentId, requestId)
.catch((error) => {
console.warn("[Session] initializeAgent failed", { agentId, error });
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
});
},
[serverId, client, setInitializingAgents]
);
const refreshAgent = useCallback(
({ agentId }: { agentId: string }) => {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
refreshAgentMutation
.mutateAsync({ agentId })
.catch((error) => {
console.warn("[Session] refreshAgent failed", { agentId, error });
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
});
},
[serverId, refreshAgentMutation, setInitializingAgents]
);
const sendAgentMessage = useCallback(
async (
agentId: string,

View File

@@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
import type { SessionState } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
@@ -334,6 +335,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
if (!session) {
throw new Error(`Host ${serverId} is not connected`);
}
const unavailableMessage = resolveVoiceUnavailableMessage({
serverInfo: session.serverInfo,
mode: "voice",
});
if (unavailableMessage) {
throw new Error(unavailableMessage);
}
setIsVoiceSwitching(true);
voiceTransportReadyRef.current = false;

View File

@@ -86,7 +86,11 @@ export class DictationStreamSender {
}
if (!this.dictationId) {
void this.restartStream("enqueue");
if (!this.startPromise) {
void this.restartStream("enqueue").catch((error) => {
console.error("[DictationStreamSender] Failed to start stream from enqueue", error);
});
}
return;
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import type { FetchAgentTimelineOptions } from "@server/client/daemon-client";
import {
attachInitTimeout,
createInitDeferred,
@@ -9,10 +10,49 @@ import {
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 5 * 60_000;
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
type TimelineCursorState = {
epoch: string;
endSeq: number;
};
function buildInitialTimelineRequest(
cursor: TimelineCursorState | undefined
): FetchAgentTimelineOptions {
if (!cursor) {
return {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
projection: "projected",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
// Catch up all missing canonical rows by default.
limit: 0,
projection: "projected",
};
}
export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useCallback(
(agentId: string, initializing: boolean) => {
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) === initializing) {
return prev;
}
const next = new Map(prev);
next.set(agentId, initializing);
return next;
});
},
[serverId, setInitializingAgents]
);
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
@@ -24,14 +64,7 @@ export function useAgentInitialization(serverId: string) {
const deferred = createInitDeferred(key);
const timeoutId = setTimeout(() => {
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
new Error(
@@ -41,34 +74,26 @@ export function useAgentInitialization(serverId: string) {
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentInitializing(agentId, true);
if (!client) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
setAgentInitializing(agentId, false);
rejectInitDeferred(key, new Error("Host is not connected"));
return deferred.promise;
}
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const timelineRequest = buildInitialTimelineRequest(cursor);
client
.initializeAgent(agentId)
.fetchAgentTimeline(agentId, timelineRequest)
.then(() => {
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
// message, handled in SessionContext.
// No-op: hydration completion is handled by SessionContext
// when it processes fetch_agent_timeline_response.
})
.catch((error) => {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
error instanceof Error ? error : new Error(String(error))
@@ -77,7 +102,7 @@ export function useAgentInitialization(serverId: string) {
return deferred.promise;
},
[client, serverId, setInitializingAgents]
[client, serverId, setAgentInitializing]
);
const refreshAgent = useCallback(
@@ -85,24 +110,21 @@ export function useAgentInitialization(serverId: string) {
if (!client) {
throw new Error("Host is not connected");
}
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentInitializing(agentId, true);
try {
await client.refreshAgent(agentId);
} catch (error) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
projection: "projected",
});
} catch (error) {
setAgentInitializing(agentId, false);
throw error;
}
},
[client, serverId, setInitializingAgents]
[client, setAgentInitializing]
);
return { ensureAgentIsInitialized, refreshAgent };

View File

@@ -1,6 +1,5 @@
import { useCallback, useMemo } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
@@ -31,106 +30,69 @@ function toAggregatedAgent(params: {
};
}
export function useAllAgentsList(): AggregatedAgentsResult {
export function useAllAgentsList(options?: {
serverId?: string | null;
}): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<
string,
NonNullable<typeof state.sessions[string]["client"]> | null
> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const client = session?.client ?? null;
const isConnected = session?.connection.isConnected ?? false;
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const agentsQuery = useQuery({
queryKey: ["allAgents", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return result;
})
);
const sessionConnections = useSessionStore(
useShallow((state) => {
const result: Record<string, boolean> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.connection.isConnected;
}
return result;
})
);
const liveAgents = useSessionStore(
useShallow((state) => {
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
}
return result;
})
);
const serverEntries = useMemo(
() =>
Object.keys(sessionClients).map((serverId) => ({
serverId,
client: sessionClients[serverId] ?? null,
isConnected: sessionConnections[serverId] ?? false,
})),
[sessionClients, sessionConnections]
);
const queries = useQueries({
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
queryKey: ["allAgents", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents();
},
enabled: Boolean(client) && isConnected,
staleTime: ALL_AGENTS_STALE_TIME,
refetchOnMount: "always" as const,
})),
return await client.fetchAgents();
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
refetchOnMount: "always" as const,
});
const refreshAll = useCallback(() => {
for (const { serverId } of serverEntries) {
void queryClient.invalidateQueries({
queryKey: ["allAgents", serverId],
});
if (!serverId) {
return;
}
}, [queryClient, serverEntries]);
void queryClient.invalidateQueries({
queryKey: ["allAgents", serverId],
});
}, [queryClient, serverId]);
const agents = useMemo(() => {
const all: AggregatedAgent[] = [];
if (!serverId) {
return [];
}
const data = agentsQuery.data ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (let idx = 0; idx < serverEntries.length; idx++) {
const entry = serverEntries[idx];
if (!entry) {
continue;
}
const data = queries[idx]?.data;
if (!data) {
continue;
}
const serverLabel =
connectionStates.get(entry.serverId)?.daemon.label ?? entry.serverId;
const liveById = liveAgents[entry.serverId];
for (const snapshot of data) {
const normalized = normalizeAgentSnapshot(snapshot, entry.serverId);
const live = liveById?.get(snapshot.id);
all.push(
toAggregatedAgent({
source: live ?? normalized,
serverId: entry.serverId,
serverLabel,
})
);
}
for (const snapshot of data) {
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
list.push(
toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
})
);
}
all.sort((left, right) => {
list.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
@@ -142,10 +104,11 @@ export function useAllAgentsList(): AggregatedAgentsResult {
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
});
return all;
}, [serverEntries, queries, connectionStates, liveAgents]);
return list;
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
const isFetching = queries.some((query) => query.isPending || query.isFetching);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && agents.length === 0;
const isRevalidating = isFetching && agents.length > 0;

View File

@@ -17,6 +17,12 @@ import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
buildHostSettingsRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { focusWithRetries } from "@/utils/web-focus";
@@ -46,19 +52,17 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
}
function parseAgentKeyFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
if (!match) return null;
return `${match[1]}:${match[2]}`;
const match = parseHostAgentRouteFromPathname(pathname);
if (!match) {
return null;
}
return `${match.serverId}:${match.agentId}`;
}
function parseAgentRouteFromPathname(
pathname: string
): { serverId: string; agentId: string } | null {
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
if (!match) return null;
const [, serverId, agentId] = match;
if (!serverId || !agentId) return null;
return { serverId, agentId };
return parseHostAgentRouteFromPathname(pathname);
}
type CommandCenterActionDefinition = {
@@ -67,7 +71,7 @@ type CommandCenterActionDefinition = {
icon?: "plus" | "settings";
shortcutKeys?: ShortcutKey[];
keywords: string[];
buildRoute: (params: { newAgentRoute: string }) => string;
buildRoute: (params: { newAgentRoute: string; settingsRoute: string }) => string;
};
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -84,7 +88,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
title: "Settings",
icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"],
buildRoute: () => "/settings",
buildRoute: ({ settingsRoute }) => settingsRoute,
},
];
@@ -137,22 +141,26 @@ export function useCommandCenter() {
return filtered;
}, [agents, query]);
const fallbackServerId = agents[0]?.serverId ?? null;
const agentKeyFromPathname = useMemo(
() => parseAgentKeyFromPathname(pathname),
[pathname]
);
const newAgentRoute = useMemo(() => {
const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId;
const routeAgent = parseAgentRouteFromPathname(pathname);
if (!routeAgent) {
return "/agent";
return serverIdFromPath ? buildNewAgentRoute(serverIdFromPath) : "/";
}
const { serverId, agentId } = routeAgent;
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = currentAgent?.cwd?.trim();
if (!cwd) {
return "/agent";
return buildNewAgentRoute(serverId);
}
const checkout =
@@ -160,8 +168,14 @@ export function useCommandCenter() {
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
return buildNewAgentRoute(workingDir);
}, [pathname]);
return buildNewAgentRoute(serverId, workingDir);
}, [fallbackServerId, pathname]);
const settingsRoute = useMemo(() => {
const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId;
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
}, [fallbackServerId, pathname]);
const actionItems = useMemo(() => {
return COMMAND_CENTER_ACTIONS.filter((action) =>
@@ -171,10 +185,10 @@ export function useCommandCenter() {
id: action.id,
title: action.title,
icon: action.icon,
route: action.buildRoute({ newAgentRoute }),
route: action.buildRoute({ newAgentRoute, settingsRoute }),
shortcutKeys: action.shortcutKeys,
}));
}, [newAgentRoute, query]);
}, [newAgentRoute, query, settingsRoute]);
const items = useMemo(() => {
const next: CommandCenterItem[] = [];
@@ -203,14 +217,14 @@ export function useCommandCenter() {
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(agentKey(agent));
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
},
[pathname, requestFocusChatInput, setOpen]
);

View File

@@ -159,9 +159,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
if (!isRecordingRef.current) {
return;
}
void startNewStream("reconnect");
void startNewStream("reconnect").catch((error) => {
reportError(error, "Failed to restart dictation stream after reconnect");
});
});
}, [client, startNewStream]);
}, [client, reportError, startNewStream]);
useEffect(() => {
if (!client) {
@@ -260,7 +262,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try {
await audio.start();
if (client?.isConnected) {
void startNewStream("start");
await startNewStream("start");
}
isRecordingRef.current = true;
setIsRecording(true);
@@ -268,9 +270,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
startDurationTracking();
}
} catch (err) {
await audio.stop().catch(() => undefined);
stopDurationTracking();
isRecordingRef.current = false;
setIsRecording(false);
setStatus("idle");
reportError(err, "Failed to start dictation");
} finally {
actionGateRef.current.starting = false;

View File

@@ -15,6 +15,11 @@ import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
export function useGlobalKeyboardNav({
enabled,
@@ -86,18 +91,20 @@ export function useGlobalKeyboardNav({
}
const { serverId, agentId } = parsed;
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(`/agent/${serverId}/${agentId}` as any);
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
};
const navigateToNewAgent = () => {
let target = "/agent";
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
targetServerId = serverId;
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
@@ -105,13 +112,21 @@ export function useGlobalKeyboardNav({
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
target = buildNewAgentRoute(workingDir);
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
}
router.push(target as any);
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
};
const handleKeyDown = (event: KeyboardEvent) => {

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -15,7 +14,8 @@ const SIDEBAR_GROUPS_STALE_TIME = 15_000;
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
const MAX_AGENTS_PER_PROJECT = 5;
type SidebarGroupsPayload = FetchAgentsGroupedByProjectResponseMessage["payload"];
type SidebarGroupsPayload =
FetchAgentsGroupedByProjectResponseMessage["payload"];
export type SidebarCheckoutLite =
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
type MutableSidebarGroup = {
@@ -68,95 +68,63 @@ function toAggregatedAgent(params: {
export function useSidebarAgentsGrouped(options?: {
isOpen?: boolean;
serverId?: string | null;
}): SidebarAgentsGroupedResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const isOpen = options?.isOpen ?? true;
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<
string,
NonNullable<typeof state.sessions[string]["client"]> | null
> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const client = session?.client ?? null;
const liveAgents = session?.agents ?? null;
const isConnected = session?.connection.isConnected ?? false;
const canFetch = Boolean(serverId && client && isConnected);
const groupedQuery = useQuery({
queryKey: ["sidebarAgentsGrouped", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return result;
})
);
const sessionAgents = useSessionStore(
useShallow((state) => {
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
}
return result;
})
);
const sessionConnections = useSessionStore(
useShallow((state) => {
const result: Record<string, boolean> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.connection.isConnected;
}
return result;
})
);
const serverEntries = useMemo(
() =>
Object.keys(sessionClients).map((serverId) => ({
serverId,
client: sessionClients[serverId] ?? null,
isConnected: sessionConnections[serverId] ?? false,
})),
[sessionClients, sessionConnections]
);
const groupedQueries = useQueries({
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
queryKey: ["sidebarAgentsGrouped", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgentsGroupedByProject({
filter: { labels: { ui: "true" } },
});
},
enabled: Boolean(client) && isConnected,
staleTime: SIDEBAR_GROUPS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
})),
return await client.fetchAgentsGroupedByProject({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: SIDEBAR_GROUPS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
if (!serverId) {
return {
sections: [] as SidebarSectionData[],
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
hasAnyData: false,
};
}
const groupsByKey = new Map<string, MutableSidebarGroup>();
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
const seenAgentKeys = new Set<string>();
const groupedFetchReadyByServer = new Map<string, boolean>();
for (let idx = 0; idx < serverEntries.length; idx++) {
const { serverId } = serverEntries[idx] ?? {};
if (!serverId) {
continue;
}
groupedFetchReadyByServer.set(serverId, groupedQueries[idx]?.isFetched ?? false);
const payload = groupedQueries[idx]?.data as SidebarGroupsPayload | undefined;
if (!payload) {
continue;
}
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const liveAgents = sessionAgents[serverId];
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
const groupedFetchReady = groupedQuery.isFetched;
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
if (payload) {
for (const group of payload.groups) {
const existing: MutableSidebarGroup =
groupsByKey.get(group.projectKey) ??
@@ -180,7 +148,10 @@ export function useSidebarAgentsGrouped(options?: {
const agentKey = `${serverId}:${entry.agent.id}`;
seenAgentKeys.add(agentKey);
checkoutLookup.set(agentKey, live?.projectPlacement?.checkout ?? entry.checkout);
checkoutLookup.set(
agentKey,
live?.projectPlacement?.checkout ?? entry.checkout
);
existing.agents.push(nextAgent);
}
@@ -188,16 +159,7 @@ export function useSidebarAgentsGrouped(options?: {
}
}
for (const { serverId } of serverEntries) {
if (!groupedFetchReadyByServer.get(serverId)) {
continue;
}
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const liveAgents = sessionAgents[serverId];
if (!liveAgents) {
continue;
}
if (groupedFetchReady && liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
@@ -270,7 +232,14 @@ export function useSidebarAgentsGrouped(options?: {
checkoutByAgentKey: checkoutLookup,
hasAnyData: nextSections.length > 0,
};
}, [serverEntries, groupedQueries, connectionStates, sessionAgents, projectOrder]);
}, [
connectionStates,
groupedQuery.data,
groupedQuery.isFetched,
liveAgents,
projectOrder,
serverId,
]);
useEffect(() => {
const currentKeys = sections.map((section) => section.projectKey);
@@ -282,16 +251,16 @@ export function useSidebarAgentsGrouped(options?: {
}, [sections, projectOrder, setProjectOrder]);
const refreshAll = useCallback(() => {
for (const { serverId } of serverEntries) {
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsGrouped", serverId],
});
if (!serverId) {
return;
}
}, [queryClient, serverEntries]);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsGrouped", serverId],
});
}, [queryClient, serverId]);
const isFetching = groupedQueries.some(
(query) => query.isPending || query.isFetching
);
const isFetching =
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
@@ -304,3 +273,4 @@ export function useSidebarAgentsGrouped(options?: {
refreshAll,
};
}

View File

@@ -45,7 +45,6 @@ import {
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/contexts/session-context";
@@ -64,6 +63,10 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useToast } from "@/contexts/toast-context";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import {
DropdownMenu,
DropdownMenuContent,
@@ -71,9 +74,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const RECONNECT_NOTICE_DELAY_MS = 10_000;
const CONNECTED_NOTICE_DURATION_MS = 2_500;
export function AgentReadyScreen({
serverId,
@@ -101,7 +107,6 @@ export function AgentReadyScreen({
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus =
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastConnectionError = connection?.lastError ?? null;
const handleBackToHome = useCallback(() => {
@@ -124,7 +129,11 @@ export function AgentReadyScreen({
targetMs: 300,
});
}
router.replace("/agent" as any);
if (targetServerId) {
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
return;
}
router.replace("/" as any);
}, [resolvedAgentId, resolvedServerId, router]);
const focusServerId = resolvedServerId;
@@ -152,7 +161,6 @@ export function AgentReadyScreen({
onBack={handleBackToHome}
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
@@ -164,6 +172,7 @@ export function AgentReadyScreen({
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
connectionStatus={connectionStatus}
/>
</ExplorerSidebarAnimationProvider>
);
@@ -172,6 +181,7 @@ export function AgentReadyScreen({
type AgentScreenContentProps = {
serverId: string;
agentId?: string;
connectionStatus: ConnectionStatus;
};
type MissingAgentState =
@@ -194,6 +204,7 @@ function isNotFoundErrorMessage(message: string): boolean {
function AgentScreenContent({
serverId,
agentId,
connectionStatus,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -362,6 +373,10 @@ function AgentScreenContent({
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions
);
const setAgents = useSessionStore((state) => state.setAgents);
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !resolvedAgentId) return new Map();
const filtered = new Map();
@@ -383,6 +398,11 @@ function AgentScreenContent({
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
kind: "idle",
});
const [showReconnectNotice, setShowReconnectNotice] = useState(false);
const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false);
const [showConnectedNotice, setShowConnectedNotice] = useState(false);
const reconnectNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectedNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initAttemptTokenRef = useRef(0);
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
@@ -410,6 +430,56 @@ function AgentScreenContent({
const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown";
useEffect(() => {
if (reconnectNoticeTimeoutRef.current) {
clearTimeout(reconnectNoticeTimeoutRef.current);
reconnectNoticeTimeoutRef.current = null;
}
if (connectionStatus === "online") {
if (showReconnectNotice || dismissedReconnectNotice) {
setShowConnectedNotice(true);
}
setShowReconnectNotice(false);
setDismissedReconnectNotice(false);
return;
}
setShowConnectedNotice(false);
if (!showReconnectNotice && !dismissedReconnectNotice) {
reconnectNoticeTimeoutRef.current = setTimeout(() => {
setShowReconnectNotice(true);
}, RECONNECT_NOTICE_DELAY_MS);
}
return () => {
if (reconnectNoticeTimeoutRef.current) {
clearTimeout(reconnectNoticeTimeoutRef.current);
reconnectNoticeTimeoutRef.current = null;
}
};
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
useEffect(() => {
if (!showConnectedNotice) {
if (connectedNoticeTimeoutRef.current) {
clearTimeout(connectedNoticeTimeoutRef.current);
connectedNoticeTimeoutRef.current = null;
}
return;
}
connectedNoticeTimeoutRef.current = setTimeout(() => {
setShowConnectedNotice(false);
connectedNoticeTimeoutRef.current = null;
}, CONNECTED_NOTICE_DURATION_MS);
return () => {
if (connectedNoticeTimeoutRef.current) {
clearTimeout(connectedNoticeTimeoutRef.current);
connectedNoticeTimeoutRef.current = null;
}
};
}, [showConnectedNotice]);
// Checkout status for header subtitle
const checkoutStatusQuery = useCheckoutStatusQuery({
serverId,
@@ -554,7 +624,7 @@ function AgentScreenContent({
return;
}
// On native clients, daemon stream forwarding is focused-agent only, so switching
// agents can leave timeline gaps unless we explicitly request a snapshot.
// agents can leave timeline gaps unless we explicitly pull timeline catch-up.
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
if (!shouldSyncOnEntry) {
return;
@@ -600,7 +670,46 @@ function AgentScreenContent({
const attemptToken = ++initAttemptTokenRef.current;
ensureAgentIsInitialized(resolvedAgentId)
.then(() => {
.then(async () => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents.get(resolvedAgentId);
if (!currentAgent && client) {
const snapshot = await client.fetchAgent(resolvedAgentId);
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
if (!snapshot) {
setMissingAgentState({
kind: "not_found",
message: `Agent not found: ${resolvedAgentId}`,
});
return;
}
const normalized = normalizeAgentSnapshot(snapshot, serverId);
setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(normalized.id, normalized);
return next;
});
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of next.entries()) {
if (pending.agentId === normalized.id) {
next.delete(key);
}
}
for (const request of normalized.pendingPermissions) {
const key = derivePendingPermissionKey(normalized.id, request);
next.set(key, { key, agentId: normalized.id, request });
}
return next;
});
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
@@ -619,10 +728,14 @@ function AgentScreenContent({
});
}, [
agent,
client,
ensureAgentIsInitialized,
isConnected,
missingAgentState.kind,
resolvedAgentId,
serverId,
setAgents,
setPendingPermissions,
shouldUseOptimisticStream,
]);
@@ -692,6 +805,17 @@ function AgentScreenContent({
</View>
);
}
if (missingAgentState.kind === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{missingAgentState.message}</Text>
</View>
</View>
);
}
return (
<View style={styles.container} testID="agent-loading">
@@ -902,6 +1026,47 @@ function AgentScreenContent({
}
/>
{(showReconnectNotice || showConnectedNotice) && (
<View
style={[
styles.connectionNotice,
showReconnectNotice
? styles.connectionNoticeReconnecting
: styles.connectionNoticeConnected,
]}
>
{showConnectedNotice ? (
<CheckCircle2
size={14}
color={theme.colors.palette.green[600]}
/>
) : null}
<Text
style={[
styles.connectionNoticeText,
showReconnectNotice
? styles.connectionNoticeTextReconnecting
: styles.connectionNoticeTextConnected,
]}
>
{showReconnectNotice ? "Reconnecting..." : "Connected"}
</Text>
{showReconnectNotice ? (
<Pressable
onPress={() => {
setShowReconnectNotice(false);
setDismissedReconnectNotice(true);
}}
style={styles.connectionNoticeDismiss}
accessibilityRole="button"
accessibilityLabel="Dismiss reconnecting notice"
>
<Text style={styles.connectionNoticeDismissText}>Dismiss</Text>
</Pressable>
) : null}
</View>
)}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
{shouldBlockForHistorySync ? (
@@ -967,14 +1132,12 @@ function AgentSessionUnavailableState({
onBack,
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
isUnknownDaemon = false,
}: {
onBack: () => void;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
@@ -1015,11 +1178,10 @@ function AgentSessionUnavailableState({
) : (
<>
<Text style={styles.offlineTitle}>
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
Reconnecting to {serverLabel}...
</Text>
<Text style={styles.offlineDescription}>
We'll reconnect automatically and show this agent as soon as the
host comes back online.
We will show this agent again as soon as the host is reachable.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
@@ -1049,6 +1211,49 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
overflow: "hidden",
},
connectionNotice: {
marginHorizontal: theme.spacing[4],
marginTop: theme.spacing[1],
marginBottom: theme.spacing[2],
minHeight: 32,
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
connectionNoticeReconnecting: {
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.yellow[400],
},
connectionNoticeConnected: {
backgroundColor: theme.colors.palette.green[100],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.green[400],
},
connectionNoticeText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
connectionNoticeTextReconnecting: {
color: theme.colors.foreground,
},
connectionNoticeTextConnected: {
color: theme.colors.palette.green[800],
},
connectionNoticeDismiss: {
marginLeft: "auto",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
},
connectionNoticeDismissText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
},

View File

@@ -14,7 +14,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Folder, GitBranch, Menu, Monitor, PanelLeft } from "lucide-react-native";
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
@@ -29,7 +29,7 @@ import {
} from "@/hooks/use-checkout-status-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -46,6 +46,7 @@ import type {
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
const DRAFT_AGENT_ID = "__new_agent__";
const EMPTY_PENDING_PERMISSIONS = new Map();
@@ -111,11 +112,13 @@ type DraftAgentParams = {
type DraftAgentScreenProps = {
isVisible?: boolean;
onCreateFlowActiveChange?: (active: boolean) => void;
forcedServerId?: string;
};
export function DraftAgentScreen({
isVisible = true,
onCreateFlowActiveChange,
forcedServerId,
}: DraftAgentScreenProps = {}) {
const { theme } = useUnistyles();
const router = useRouter();
@@ -143,7 +146,11 @@ export function DraftAgentScreen({
};
});
const resolvedServerId = getParamValue(params.serverId);
const forcedServerIdParam = forcedServerId?.trim();
const resolvedServerId =
forcedServerIdParam && forcedServerIdParam.length > 0
? forcedServerIdParam
: getParamValue(params.serverId);
const resolvedProvider = getValidProvider(getParamValue(params.provider));
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
const resolvedModel = getParamValue(params.model);
@@ -214,25 +221,9 @@ export function DraftAgentScreen({
isCreateFlow: true,
onlineServerIds,
});
const daemonLabelByServerId = useMemo(() => {
const map = new Map<string, string>();
for (const daemon of daemons) {
const label = daemon.label?.trim();
map.set(daemon.serverId, label && label.length > 0 ? label : daemon.serverId);
}
return map;
}, [daemons]);
const hostEntry = selectedServerId
? connectionStates.get(selectedServerId)
: undefined;
const selectedDaemonLabel = selectedServerId
? daemonLabelByServerId.get(selectedServerId)
: null;
const hostLabel =
selectedDaemonLabel ??
hostEntry?.daemon.label ??
selectedServerId ??
"Select host";
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
@@ -241,7 +232,6 @@ export function DraftAgentScreen({
? theme.colors.foreground
: theme.colors.foregroundMuted;
const [isHostOpen, setIsHostOpen] = useState(false);
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
const [baseBranch, setBaseBranch] = useState("");
const [worktreeSlug, setWorktreeSlug] = useState("");
@@ -249,7 +239,8 @@ export function DraftAgentScreen({
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
const [isBranchOpen, setIsBranchOpen] = useState(false);
const hostAnchorRef = useRef<View>(null);
const [branchSearchQuery, setBranchSearchQuery] = useState("");
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
const workingDirAnchorRef = useRef<View>(null);
const worktreeAnchorRef = useRef<View>(null);
const branchAnchorRef = useRef<View>(null);
@@ -258,6 +249,12 @@ export function DraftAgentScreen({
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
useEffect(() => {
const trimmed = branchSearchQuery.trim();
const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [branchSearchQuery]);
type CreateAttempt = {
messageId: string;
text: string;
@@ -489,6 +486,40 @@ export function DraftAgentScreen({
? "Select a worktree to attach"
: null;
const branchSuggestionsQuery = useQuery({
queryKey: [
"branchSuggestions",
selectedServerId,
trimmedWorkingDir,
debouncedBranchSearchQuery,
],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getBranchSuggestions({
cwd: trimmedWorkingDir || ".",
query: debouncedBranchSearchQuery || undefined,
limit: 50,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.branches ?? [];
},
enabled:
isCreateWorktree &&
isGitDirectory &&
!isNonGitDirectory &&
Boolean(trimmedWorkingDir) &&
!repoAvailabilityError &&
Boolean(sessionClient) &&
isConnected,
retry: false,
staleTime: 15_000,
});
const validateWorktreeName = useCallback(
(name: string): { valid: boolean; error?: string } => {
if (!name) {
@@ -627,17 +658,6 @@ export function DraftAgentScreen({
worktreeMode === "create"
? "Create new worktree"
: selectedWorktreeLabel || "Select worktree";
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemonLabelByServerId.get(daemon.serverId) ?? daemon.serverId,
description: formatConnectionStatus(
connectionStates.get(daemon.serverId)?.status ?? "idle"
),
})),
[connectionStates, daemonLabelByServerId, daemons]
);
const worktreeComboOptions = useMemo(
() => [
{
@@ -658,21 +678,36 @@ export function DraftAgentScreen({
);
const branchComboOptions = useMemo(() => {
const branchSet = new Set<string>();
const currentBranch = checkout?.isGit ? checkout.currentBranch?.trim() : null;
if (currentBranch && currentBranch !== "HEAD") {
branchSet.add(currentBranch);
const options = buildBranchComboOptions({
suggestedBranches: branchSuggestionsQuery.data ?? [],
currentBranch: checkout?.isGit ? checkout.currentBranch : null,
baseRef: checkout?.isGit ? checkout.baseRef : null,
typedBaseBranch: baseBranch,
worktreeBranchLabels: worktreeOptions.map((option) => option.label),
});
const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
if (!normalizedQuery) {
return options;
}
if (baseBranch.trim()) {
branchSet.add(baseBranch.trim());
}
for (const option of worktreeOptions) {
if (option.label) {
branchSet.add(option.label);
return options.sort((a, b) => {
const aLower = a.label.toLowerCase();
const bLower = b.label.toLowerCase();
const aPrefix = aLower.startsWith(normalizedQuery);
const bPrefix = bLower.startsWith(normalizedQuery);
if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1;
}
}
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
}, [baseBranch, checkout, worktreeOptions]);
return aLower.localeCompare(bLower);
});
}, [
baseBranch,
branchSearchQuery,
branchSuggestionsQuery.data,
checkout,
worktreeOptions,
]);
const createAgentClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
@@ -872,7 +907,9 @@ export function DraftAgentScreen({
const agentId = (result as { id?: string })?.id;
if (agentId && selectedServerId) {
updatePendingAgentId(agentId);
router.replace(`/agent/${selectedServerId}/${agentId}` as any);
router.replace(
buildHostAgentDetailRoute(selectedServerId, agentId) as any
);
return;
}
@@ -973,7 +1010,7 @@ export function DraftAgentScreen({
<View style={isMobile ? styles.stackedSelectorGroup : styles.topSelectorRow}>
<FormSelectTrigger
controlRef={workingDirAnchorRef}
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorPrimary}
containerStyle={styles.fullSelector}
label="Working directory"
value={displayWorkingDir}
placeholder="/path/to/project"
@@ -981,16 +1018,7 @@ export function DraftAgentScreen({
icon={<Folder size={16} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
/>
<FormSelectTrigger
controlRef={hostAnchorRef}
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorSecondary}
label="Host"
value={hostLabel}
placeholder="Select host"
onPress={() => setIsHostOpen(true)}
icon={<Monitor size={16} color={theme.colors.foregroundMuted} />}
showLabel={false}
testID="working-directory-select"
/>
</View>
{isDirectoryNotExists && (
@@ -1059,17 +1087,6 @@ export function DraftAgentScreen({
{attachWorktreeError ? <Text style={styles.errorInlineText}>{attachWorktreeError}</Text> : null}
{worktreeOptionsError ? <Text style={styles.errorInlineText}>{worktreeOptionsError}</Text> : null}
</View>
<Combobox
options={hostOptions}
value={selectedServerId ?? ""}
onSelect={(serverId) => setSelectedServerIdFromUser(serverId)}
title="Host"
searchPlaceholder="Search hosts..."
open={isHostOpen}
onOpenChange={setIsHostOpen}
anchorRef={hostAnchorRef}
/>
<Combobox
options={worktreeComboOptions}
value={
@@ -1130,13 +1147,19 @@ export function DraftAgentScreen({
options={branchComboOptions}
value={baseBranch}
onSelect={handleBaseBranchChange}
onSearchQueryChange={setBranchSearchQuery}
searchPlaceholder="Choose a base branch..."
allowCustomValue
customValuePrefix="Use"
customValueDescription="Use this branch name"
title="Select base branch"
open={isBranchOpen}
onOpenChange={setIsBranchOpen}
onOpenChange={(nextOpen) => {
setIsBranchOpen(nextOpen);
if (!nextOpen) {
setBranchSearchQuery("");
}
}}
anchorRef={branchAnchorRef}
/>

View File

@@ -7,6 +7,10 @@ import { BackHeader } from "@/components/headers/back-header";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { Agent } from "@/contexts/session-context";
import {
buildHostAgentDetailRoute,
buildHostAgentDraftRoute,
} from "@/utils/host-routes";
type AgentMatch = {
serverId: string;
@@ -50,16 +54,21 @@ export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
return;
}
const match = matches[0];
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
}, [isRedirecting, matches, router]);
const handleGoDraft = useCallback(() => {
router.replace("/agent" as any);
}, [router]);
const firstMatchServerId = matches[0]?.serverId ?? null;
if (firstMatchServerId) {
router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any);
return;
}
router.replace("/" as any);
}, [matches, router]);
const handleSelectMatch = useCallback(
(match: AgentMatch) => {
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
},
[router]
);

View File

@@ -4,9 +4,13 @@ import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { router } from "expo-router";
export default function AgentsScreen() {
const { agents, isRevalidating, refreshAll } = useAllAgentsList();
export function AgentsScreen({ serverId }: { serverId: string }) {
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
serverId,
});
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -33,7 +37,10 @@ export default function AgentsScreen() {
return (
<View style={styles.container}>
<BackHeader title="All agents" />
<BackHeader
title="All agents"
onBack={() => router.replace(buildHostAgentDraftRoute(serverId) as any)}
/>
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}

View File

@@ -405,7 +405,8 @@ const styles = StyleSheet.create((theme) => ({
export default function SettingsScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams<{ editHost?: string }>();
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const {
daemons,
@@ -429,8 +430,9 @@ export default function SettingsScreen() {
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
const editingDaemonLive = editingDaemon
? daemons.find((daemon) => daemon.serverId === editingDaemon.serverId) ?? null
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
: null;
const pendingNameHostname = useSessionStore(
useCallback(
@@ -448,6 +450,13 @@ export default function SettingsScreen() {
};
}, []);
// Keep the edit modal bound to live registry state.
useEffect(() => {
if (!editingServerId) return;
if (editingDaemonLive) return;
setEditingDaemon(null);
}, [editingDaemonLive, editingServerId]);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
@@ -516,7 +525,7 @@ export default function SettingsScreen() {
]);
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingDaemon) return;
if (!editingServerId) return;
if (isSavingEdit) return;
const nextLabel = nextLabelRaw.trim();
@@ -527,7 +536,7 @@ export default function SettingsScreen() {
try {
setIsSavingEdit(true);
await updateHost(editingDaemon.serverId, { label: nextLabel });
await updateHost(editingServerId, { label: nextLabel });
handleCloseEditDaemon();
} catch (error) {
console.error("[Settings] Failed to rename host", error);
@@ -535,7 +544,7 @@ export default function SettingsScreen() {
} finally {
setIsSavingEdit(false);
}
}, [editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]);
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
const handleRemoveConnection = useCallback(
async (serverId: string, connectionId: string) => {
@@ -550,13 +559,13 @@ export default function SettingsScreen() {
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingDaemon) return;
const serverId = editingDaemon.serverId;
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingDaemon]);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
@@ -666,16 +675,19 @@ export default function SettingsScreen() {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId ? { source, targetServerId } : { source },
});
}}
/>
onScanQr={() => {
const targetServerId = addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = routeServerId || targetServerId || undefined;
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
});
}}
/>
<AddHostModal
visible={isDirectHostVisible}
@@ -763,11 +775,11 @@ export default function SettingsScreen() {
) : null}
<HostDetailModal
visible={Boolean(editingDaemon)}
host={editingDaemonLive ?? editingDaemon}
connectionStatus={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.status ?? "idle") : "idle"}
activeConnection={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.activeConnection ?? null) : null}
lastError={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.lastError ?? null) : null}
visible={Boolean(editingDaemonLive)}
host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)}
@@ -873,6 +885,7 @@ function HostDetailModal({
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState("");
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
const activeServerIdRef = useRef<string | null>(null);
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
@@ -1018,22 +1031,30 @@ function HostDetailModal({
})();
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const handleDraftLabelChange = useCallback((nextValue: string) => {
setDraftLabel(nextValue);
setIsDraftLabelDirty(true);
}, []);
useEffect(() => {
if (!visible || !host) return;
if (activeServerIdRef.current !== host.serverId) {
const hostChanged = activeServerIdRef.current !== host.serverId;
if (hostChanged) {
setDraftLabel(host.label ?? "");
setIsDraftLabelDirty(false);
activeServerIdRef.current = host.serverId;
return;
}
if (!draftLabel.trim()) {
if (!isDraftLabelDirty) {
setDraftLabel(host.label ?? "");
}
}, [visible, host, draftLabel]);
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
useEffect(() => {
if (!visible) {
activeServerIdRef.current = null;
setIsRestarting(false);
setIsDraftLabelDirty(false);
}
}, [visible]);
@@ -1072,7 +1093,7 @@ function HostDetailModal({
<AdaptiveTextInput
style={styles.input}
value={draftLabel}
onChangeText={setDraftLabel}
onChangeText={handleDraftLabelChange}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>

View File

@@ -27,7 +27,7 @@ interface DesktopSidebarState {
fileExplorerOpen: boolean;
}
export type ExplorerTab = "changes" | "files";
export type ExplorerTab = "changes" | "files" | "terminals";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;

View File

@@ -21,6 +21,7 @@ import type {
FileDownloadTokenResponse,
GitSetupOptions,
ProjectPlacementPayload,
ServerCapabilities,
} from "@server/shared/messages";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -155,8 +156,15 @@ export interface DaemonConnectionSnapshot {
export type DaemonServerInfo = {
serverId: string;
hostname: string | null;
capabilities?: ServerCapabilities;
};
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
}
// Per-session state
export interface SessionState {
serverId: string;
@@ -189,6 +197,7 @@ export interface SessionState {
// Stream state (head/tail model)
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
@@ -240,6 +249,12 @@ interface SessionStoreActions {
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
clearAgentStreamHead: (serverId: string, agentId: string) => void;
setAgentTimelineCursor: (
serverId: string,
state:
| Map<string, AgentTimelineCursorState>
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>)
) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
@@ -321,6 +336,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
currentAssistantMessage: "",
agentStreamTail: new Map(),
agentStreamHead: new Map(),
agentTimelineCursor: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
initializingAgents: new Map(),
@@ -331,6 +347,13 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
};
}
function areServerCapabilitiesEqual(
current: ServerCapabilities | undefined,
next: ServerCapabilities | undefined
): boolean {
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
export const useSessionStore = create<SessionStore>()(
subscribeWithSelector((set, get) => ({
sessions: {},
@@ -432,8 +455,14 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null;
const prevHostname = session.serverInfo?.hostname?.trim() || null;
const nextCapabilities = info.capabilities;
const prevCapabilities = session.serverInfo?.capabilities;
if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) {
if (
session.serverInfo?.serverId === info.serverId &&
prevHostname === nextHostname &&
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
) {
return prev;
}
@@ -448,7 +477,11 @@ export const useSessionStore = create<SessionStore>()(
...prev.sessions,
[serverId]: {
...session,
serverInfo: { serverId: info.serverId, hostname: nextHostname },
serverInfo: {
serverId: info.serverId,
hostname: nextHostname,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
},
},
},
};
@@ -603,6 +636,30 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentTimelineCursor: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineCursor) : state;
if (session.agentTimelineCursor === nextState) {
return prev;
}
logSessionStoreUpdate("setAgentTimelineCursor", serverId, {
agentCount: nextState.size,
});
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineCursor: nextState },
},
};
});
},
bumpHistorySyncGeneration: (serverId) => {
set((prev) => {
const session = prev.sessions[serverId];

View File

@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem, ThoughtItem } from "@/types/stream";
import {
applyStreamEvent,
applyStreamEventWithBuffer,
type StreamingBufferEntry,
} from "@/types/stream";
import type { ThoughtItem } from "@/types/stream";
import { applyStreamEvent } from "@/types/stream";
const baseTimestamp = new Date(0);
@@ -23,7 +19,7 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
provider: "codex",
item: {
type: "tool_call",
callId: "buffer-tool-call",
callId: "head-tail-tool-call",
name: "run",
status: "running",
detail: {
@@ -60,122 +56,7 @@ const reasoningChunk = (text: string): AgentStreamEventPayload => ({
},
});
describe("applyStreamEventWithBuffer", () => {
it("buffers assistant chunks without changing stream", () => {
const stream: StreamItem[] = [];
const result = applyStreamEventWithBuffer({
state: stream,
buffer: null,
event: assistantChunk("Hel"),
timestamp: baseTimestamp,
});
expect(result.stream).toBe(stream);
expect(result.changedStream).toBe(false);
expect(result.buffer?.text).toBe("Hel");
});
it("appends assistant chunks to the buffer", () => {
const stream: StreamItem[] = [];
const initial = applyStreamEventWithBuffer({
state: stream,
buffer: null,
event: assistantChunk("Hel"),
timestamp: baseTimestamp,
});
const next = applyStreamEventWithBuffer({
state: stream,
buffer: initial.buffer,
event: assistantChunk("lo"),
timestamp: baseTimestamp,
});
expect(next.buffer?.text).toBe("Hello");
expect(next.changedStream).toBe(false);
});
it("commits buffered message on completion", () => {
const stream: StreamItem[] = [];
const buffered = applyStreamEventWithBuffer({
state: stream,
buffer: null,
event: assistantChunk("Hello"),
timestamp: baseTimestamp,
});
const result = applyStreamEventWithBuffer({
state: stream,
buffer: buffered.buffer,
event: completionEvent(),
timestamp: baseTimestamp,
});
expect(result.buffer).toBe(null);
expect(result.stream).toHaveLength(1);
expect(result.stream[0].kind).toBe("assistant_message");
expect((result.stream[0] as { text: string }).text).toBe("Hello");
});
it("commits buffer before non-assistant timeline items", () => {
const stream: StreamItem[] = [];
const buffered = applyStreamEventWithBuffer({
state: stream,
buffer: null,
event: assistantChunk("Hello"),
timestamp: baseTimestamp,
});
const result = applyStreamEventWithBuffer({
state: stream,
buffer: buffered.buffer,
event: toolCallEvent(),
timestamp: baseTimestamp,
});
expect(result.buffer).toBe(null);
expect(result.stream).toHaveLength(2);
expect(result.stream[0].kind).toBe("assistant_message");
expect(result.stream[1].kind).toBe("tool_call");
});
it("keeps stream reference for no-op events", () => {
const stream: StreamItem[] = [];
const result = applyStreamEventWithBuffer({
state: stream,
buffer: null,
event: permissionEvent(),
timestamp: baseTimestamp,
});
expect(result.stream).toBe(stream);
expect(result.changedStream).toBe(false);
});
it("avoids double-committing an already flushed buffer", () => {
const stream: StreamItem[] = [
{
kind: "assistant_message",
id: "dup",
text: "Hello",
timestamp: baseTimestamp,
},
];
const buffer: StreamingBufferEntry = {
id: "dup",
text: "Hello",
timestamp: baseTimestamp,
};
const result = applyStreamEventWithBuffer({
state: stream,
buffer,
event: completionEvent(),
timestamp: baseTimestamp,
});
expect(result.stream).toBe(stream);
expect(result.buffer).toBe(null);
});
});
describe("applyStreamEvent (head/tail model)", () => {
describe("applyStreamEvent", () => {
it("buffers reasoning chunks in head", () => {
const result = applyStreamEvent({
tail: [],
@@ -270,4 +151,21 @@ describe("applyStreamEvent (head/tail model)", () => {
expect(result.head).toHaveLength(1);
expect(result.head[0].kind).toBe("assistant_message");
});
it("keeps references stable for no-op events", () => {
const tail: ReturnType<typeof applyStreamEvent>["tail"] = [];
const head: ReturnType<typeof applyStreamEvent>["head"] = [];
const result = applyStreamEvent({
tail,
head,
event: permissionEvent(),
timestamp: baseTimestamp,
});
expect(result.tail).toBe(tail);
expect(result.head).toBe(head);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
});
});

View File

@@ -899,56 +899,3 @@ export function applyStreamEvent(params: {
return { tail: nextTail, head: nextHead, changedTail, changedHead };
}
// Legacy export for backwards compatibility during migration
// TODO: Remove after all consumers are updated
export type StreamingBufferEntry = {
id: string;
text: string;
timestamp: Date;
};
export function applyStreamEventWithBuffer(params: {
state: StreamItem[];
buffer: StreamingBufferEntry | null;
event: AgentStreamEventPayload;
timestamp: Date;
}): {
stream: StreamItem[];
buffer: StreamingBufferEntry | null;
changedStream: boolean;
changedBuffer: boolean;
} {
// Convert legacy buffer to head
const head: StreamItem[] = params.buffer
? [
{
kind: "assistant_message",
id: params.buffer.id,
text: params.buffer.text,
timestamp: params.buffer.timestamp,
},
]
: [];
const result = applyStreamEvent({
tail: params.state,
head,
event: params.event,
timestamp: params.timestamp,
});
// Convert head back to legacy buffer format
const lastHead = result.head[result.head.length - 1];
const newBuffer: StreamingBufferEntry | null =
lastHead && lastHead.kind === "assistant_message"
? { id: lastHead.id, text: lastHead.text, timestamp: lastHead.timestamp }
: null;
return {
stream: result.tail,
buffer: newBuffer,
changedStream: result.changedTail,
changedBuffer: result.changedHead,
};
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildBranchComboOptions, normalizeBranchOptionName } from "./branch-suggestions";
describe("normalizeBranchOptionName", () => {
it("normalizes local and origin-prefixed refs", () => {
expect(normalizeBranchOptionName("refs/heads/main")).toBe("main");
expect(normalizeBranchOptionName("refs/remotes/origin/main")).toBe("main");
expect(normalizeBranchOptionName("origin/feature/test")).toBe("feature/test");
expect(normalizeBranchOptionName("feature/test")).toBe("feature/test");
});
it("filters out empty values and HEAD", () => {
expect(normalizeBranchOptionName("")).toBeNull();
expect(normalizeBranchOptionName(" ")).toBeNull();
expect(normalizeBranchOptionName("HEAD")).toBeNull();
expect(normalizeBranchOptionName("origin/HEAD")).toBeNull();
});
});
describe("buildBranchComboOptions", () => {
it("merges branch sources and de-duplicates normalized names", () => {
const options = buildBranchComboOptions({
suggestedBranches: ["origin/main", "refs/remotes/origin/main", "feature/a"],
currentBranch: "refs/heads/feature/a",
baseRef: "origin/main",
typedBaseBranch: "main",
worktreeBranchLabels: ["refs/heads/release/next"],
});
expect(options).toEqual([
{ id: "main", label: "main" },
{ id: "feature/a", label: "feature/a" },
{ id: "release/next", label: "release/next" },
]);
});
});

View File

@@ -0,0 +1,51 @@
export type BranchComboOption = {
id: string;
label: string;
};
export function normalizeBranchOptionName(input: string | null | undefined): string | null {
const trimmed = input?.trim();
if (!trimmed || trimmed === "HEAD") {
return null;
}
let normalized = trimmed;
if (normalized.startsWith("refs/heads/")) {
normalized = normalized.slice("refs/heads/".length);
} else if (normalized.startsWith("refs/remotes/")) {
normalized = normalized.slice("refs/remotes/".length);
}
if (normalized.startsWith("origin/")) {
normalized = normalized.slice("origin/".length);
}
return normalized.length > 0 && normalized !== "HEAD" ? normalized : null;
}
export function buildBranchComboOptions(input: {
suggestedBranches?: string[];
currentBranch?: string | null;
baseRef?: string | null;
typedBaseBranch?: string | null;
worktreeBranchLabels?: string[];
}): BranchComboOption[] {
const branchSet = new Set<string>();
const addBranch = (name: string | null | undefined) => {
const normalized = normalizeBranchOptionName(name);
if (normalized) {
branchSet.add(normalized);
}
};
for (const branch of input.suggestedBranches ?? []) {
addBranch(branch);
}
addBranch(input.currentBranch ?? null);
addBranch(input.baseRef ?? null);
addBranch(input.typedBaseBranch ?? null);
for (const label of input.worktreeBranchLabels ?? []) {
addBranch(label);
}
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
}

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { type HostConnection } from "@/contexts/daemon-registry-context";
import {
selectBestConnection,
type ConnectionCandidate,
type ConnectionProbeState,
} from "./connection-selection";
function makeDirect(id: string, endpoint: string): HostConnection {
return { id, type: "direct", endpoint };
}
function makeRelay(
id: string,
relayEndpoint: string,
daemonPublicKeyB64 = "abc"
): HostConnection {
return { id, type: "relay", relayEndpoint, daemonPublicKeyB64 };
}
function probes(
input: Record<string, ConnectionProbeState>
): Map<string, ConnectionProbeState> {
return new Map(Object.entries(input));
}
describe("selectBestConnection", () => {
it("picks the available connection with lowest latency regardless of transport type", () => {
const candidates: ConnectionCandidate[] = [
{ connectionId: "direct:a", connection: makeDirect("direct:a", "a:6767") },
{
connectionId: "relay:b",
connection: makeRelay("relay:b", "relay.example:443"),
},
];
const selected = selectBestConnection({
candidates,
preferredConnectionId: "direct:a",
probeByConnectionId: probes({
"direct:a": { status: "available", latencyMs: 84 },
"relay:b": { status: "available", latencyMs: 34 },
}),
});
expect(selected).toBe("relay:b");
});
it("ignores unavailable and pending probes", () => {
const candidates: ConnectionCandidate[] = [
{ connectionId: "direct:a", connection: makeDirect("direct:a", "a:6767") },
{
connectionId: "relay:b",
connection: makeRelay("relay:b", "relay.example:443"),
},
{ connectionId: "direct:c", connection: makeDirect("direct:c", "c:6767") },
];
const selected = selectBestConnection({
candidates,
preferredConnectionId: "direct:a",
probeByConnectionId: probes({
"direct:a": { status: "pending", latencyMs: null },
"relay:b": { status: "unavailable", latencyMs: null },
"direct:c": { status: "available", latencyMs: 41 },
}),
});
expect(selected).toBe("direct:c");
});
it("falls back to preferred connection when no candidates are available", () => {
const candidates: ConnectionCandidate[] = [
{ connectionId: "direct:a", connection: makeDirect("direct:a", "a:6767") },
{
connectionId: "relay:b",
connection: makeRelay("relay:b", "relay.example:443"),
},
];
const selected = selectBestConnection({
candidates,
preferredConnectionId: "relay:b",
probeByConnectionId: probes({
"direct:a": { status: "pending", latencyMs: null },
"relay:b": { status: "unavailable", latencyMs: null },
}),
});
expect(selected).toBe("relay:b");
});
it("falls back to first candidate when preferred is missing and none are available", () => {
const candidates: ConnectionCandidate[] = [
{ connectionId: "direct:a", connection: makeDirect("direct:a", "a:6767") },
{
connectionId: "relay:b",
connection: makeRelay("relay:b", "relay.example:443"),
},
];
const selected = selectBestConnection({
candidates,
preferredConnectionId: "missing",
probeByConnectionId: probes({
"direct:a": { status: "unavailable", latencyMs: null },
"relay:b": { status: "pending", latencyMs: null },
}),
});
expect(selected).toBe("direct:a");
});
});

View File

@@ -0,0 +1,65 @@
import type { HostConnection } from "@/contexts/daemon-registry-context";
export type ConnectionCandidate = {
connectionId: string;
connection: HostConnection;
};
export type ConnectionProbeState =
| { status: "pending"; latencyMs: null }
| { status: "unavailable"; latencyMs: null }
| { status: "available"; latencyMs: number };
export type SelectBestConnectionInput = {
candidates: ConnectionCandidate[];
preferredConnectionId: string | null;
probeByConnectionId: Map<string, ConnectionProbeState>;
};
export function selectBestConnection(
input: SelectBestConnectionInput
): string | null {
const { candidates, preferredConnectionId, probeByConnectionId } = input;
if (candidates.length === 0) {
return null;
}
const available = candidates
.map((candidate, index) => ({ candidate, index }))
.filter(({ candidate }) => {
const probe = probeByConnectionId.get(candidate.connectionId);
return probe?.status === "available";
})
.sort((left, right) => {
const leftProbe = probeByConnectionId.get(left.candidate.connectionId);
const rightProbe = probeByConnectionId.get(right.candidate.connectionId);
const leftLatency =
leftProbe && leftProbe.status === "available"
? leftProbe.latencyMs
: Number.POSITIVE_INFINITY;
const rightLatency =
rightProbe && rightProbe.status === "available"
? rightProbe.latencyMs
: Number.POSITIVE_INFINITY;
if (leftLatency === rightLatency) {
return left.index - right.index;
}
return leftLatency - rightLatency;
});
if (available.length > 0) {
return available[0]!.candidate.connectionId;
}
const preferred = candidates.find(
(candidate) => candidate.connectionId === preferredConnectionId
);
if (preferred) {
return preferred.connectionId;
}
return candidates[0]!.connectionId;
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll";
describe("shouldAnchorHeaderBeforeCollapse", () => {
it("skips anchor when header is fully visible in viewport", () => {
expect(
shouldAnchorHeaderBeforeCollapse({
headerOffset: 120,
headerHeight: 44,
viewportOffset: 80,
viewportHeight: 400,
})
).toBe(false);
});
it("skips anchor when header is partially visible in viewport", () => {
expect(
shouldAnchorHeaderBeforeCollapse({
headerOffset: 60,
headerHeight: 44,
viewportOffset: 80,
viewportHeight: 300,
})
).toBe(false);
});
it("anchors when header is above viewport", () => {
expect(
shouldAnchorHeaderBeforeCollapse({
headerOffset: 200,
headerHeight: 44,
viewportOffset: 500,
viewportHeight: 300,
})
).toBe(true);
});
it("anchors when header is below viewport", () => {
expect(
shouldAnchorHeaderBeforeCollapse({
headerOffset: 600,
headerHeight: 44,
viewportOffset: 100,
viewportHeight: 300,
})
).toBe(true);
});
it("anchors when viewport metrics are unavailable", () => {
expect(
shouldAnchorHeaderBeforeCollapse({
headerOffset: 0,
headerHeight: 44,
viewportOffset: 0,
viewportHeight: 0,
})
).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
interface AnchorVisibilityInput {
headerOffset: number;
headerHeight: number;
viewportOffset: number;
viewportHeight: number;
edgeThreshold?: number;
}
export function shouldAnchorHeaderBeforeCollapse({
headerOffset,
headerHeight,
viewportOffset,
viewportHeight,
edgeThreshold = 1,
}: AnchorVisibilityInput): boolean {
if (
!Number.isFinite(headerOffset) ||
!Number.isFinite(headerHeight) ||
!Number.isFinite(viewportOffset) ||
!Number.isFinite(viewportHeight) ||
viewportHeight <= 0
) {
// Preserve current behavior when metrics are unavailable.
return true;
}
const clampedThreshold = Math.max(0, edgeThreshold);
const clampedHeaderHeight = Math.max(0, headerHeight);
const headerStart = headerOffset;
const headerEnd = headerOffset + clampedHeaderHeight;
const viewportStart = viewportOffset + clampedThreshold;
const viewportEnd = viewportOffset + viewportHeight - clampedThreshold;
const headerVisible = headerEnd > viewportStart && headerStart < viewportEnd;
return !headerVisible;
}

View File

@@ -0,0 +1,118 @@
type NullableString = string | null | undefined;
function trimNonEmpty(value: NullableString): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function encodeSegment(value: string): string {
return encodeURIComponent(value);
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
return null;
}
const raw = match[1];
if (!raw) {
return null;
}
try {
return trimNonEmpty(decodeURIComponent(raw));
} catch {
return trimNonEmpty(raw);
}
}
export function parseHostAgentRouteFromPathname(
pathname: string
): { serverId: string; agentId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
if (!match) {
return null;
}
const [, encodedServerId, encodedAgentId] = match;
if (!encodedServerId || !encodedAgentId) {
return null;
}
const decode = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const serverId = trimNonEmpty(decode(encodedServerId));
const agentId = trimNonEmpty(decode(encodedAgentId));
if (!serverId || !agentId) {
return null;
}
return { serverId, agentId };
}
export function buildHostAgentDraftRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
return "/";
}
return `/h/${encodeSegment(normalized)}/agent`;
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string
): string {
const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedServerId || !normalizedAgentId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/agent/${encodeSegment(
normalizedAgentId
)}`;
}
export function buildHostAgentsRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
return "/";
}
return `/h/${encodeSegment(normalized)}/agents`;
}
export function buildHostSettingsRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
return "/";
}
return `/h/${encodeSegment(normalized)}/settings`;
}
export function mapPathnameToServer(
pathname: string,
nextServerId: string
): string {
const normalized = trimNonEmpty(nextServerId);
if (!normalized) {
return "/";
}
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
const base = `/h/${encodeSegment(normalized)}`;
if (suffix.startsWith("settings")) {
return `${base}/settings`;
}
if (suffix.startsWith("agents")) {
return `${base}/agents`;
}
return `${base}/agent`;
}

View File

@@ -7,14 +7,14 @@ import {
} from "./new-agent-routing";
describe("buildNewAgentRoute", () => {
it("falls back to /agent when no working directory is provided", () => {
expect(buildNewAgentRoute(undefined)).toBe("/agent");
expect(buildNewAgentRoute(" ")).toBe("/agent");
it("falls back to host-scoped draft route when no working directory is provided", () => {
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/agent");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent");
});
it("encodes the working directory query parameter", () => {
expect(buildNewAgentRoute("/Users/me/dev/paseo")).toBe(
"/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
"/h/srv-1/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
);
});
});

View File

@@ -1,4 +1,5 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export function resolveNewAgentWorkingDir(
cwd: string,
@@ -7,10 +8,17 @@ export function resolveNewAgentWorkingDir(
return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd;
}
export function buildNewAgentRoute(workingDir?: string | null): string {
export function buildNewAgentRoute(
serverId: string,
workingDir?: string | null
): string {
const baseRoute = buildHostAgentDraftRoute(serverId);
if (baseRoute === "/") {
return baseRoute;
}
const trimmedWorkingDir = workingDir?.trim();
if (!trimmedWorkingDir) {
return "/agent";
return baseRoute;
}
return `/agent?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
return `${baseRoute}?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
}

View File

@@ -33,16 +33,17 @@ describe("resolveNotificationTarget", () => {
describe("buildNotificationRoute", () => {
it("routes directly to server-scoped agent path when both ids are present", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
"/agent/srv-1/agent-1"
"/h/srv-1/agent/agent-1"
);
});
it("falls back to legacy agent route when serverId is absent", () => {
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy");
it("falls back to host-scoped draft route when only serverId is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/agent");
});
it("falls back to agents list when no agent id is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents");
it("falls back to root when no server id is present", () => {
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
expect(buildNotificationRoute(undefined)).toBe("/");
});
it("encodes path segments", () => {
@@ -51,6 +52,6 @@ describe("buildNotificationRoute", () => {
serverId: "srv/with/slash",
agentId: "agent with space",
})
).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space");
).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space");
});
});

View File

@@ -1,3 +1,8 @@
import {
buildHostAgentDetailRoute,
buildHostAgentDraftRoute,
} from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
function readNonEmptyString(
@@ -25,10 +30,10 @@ export function resolveNotificationTarget(data: NotificationData): {
export function buildNotificationRoute(data: NotificationData): string {
const { serverId, agentId } = resolveNotificationTarget(data);
if (serverId && agentId) {
return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`;
return buildHostAgentDetailRoute(serverId, agentId);
}
if (agentId) {
return `/agent/${encodeURIComponent(agentId)}`;
if (serverId) {
return buildHostAgentDraftRoute(serverId);
}
return "/agents";
return "/";
}

View File

@@ -161,6 +161,6 @@ describe("sendOsNotification", () => {
expect(clicked.onclick).toBeTypeOf("function");
clicked.onclick?.({} as Event);
expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1");
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import type { ServerCapabilities } from "@server/shared/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
import {
getServerCapabilities,
getVoiceReadinessState,
resolveVoiceUnavailableMessage,
} from "./server-info-capabilities";
function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
return {
serverId: "srv-1",
hostname: "test-host",
...(capabilities ? { capabilities } : {}),
};
}
describe("server-info-capabilities", () => {
it("returns null capabilities when server_info does not include capability metadata", () => {
const serverInfo = buildServerInfo();
expect(getServerCapabilities({ serverInfo })).toBeNull();
});
it("returns the matching voice capability state by mode", () => {
const capabilities: ServerCapabilities = {
voice: {
dictation: {
enabled: true,
reason: "Dictation is warming up.",
},
voice: {
enabled: false,
reason: "Voice is disabled in daemon config.",
},
},
};
const serverInfo = buildServerInfo(capabilities);
expect(
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})
).toEqual(capabilities.voice?.dictation);
expect(
getVoiceReadinessState({
serverInfo,
mode: "voice",
})
).toEqual(capabilities.voice?.voice);
});
it("returns null when capability is enabled and has no reason", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: true,
reason: "",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBeNull();
});
it("returns capability reason when present", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: true,
reason: "Dictation models are still downloading.",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBe("Dictation models are still downloading.");
});
it("returns null when capability reason is blank", () => {
const serverInfo = buildServerInfo({
voice: {
dictation: {
enabled: false,
reason: " ",
},
voice: {
enabled: true,
reason: "",
},
},
});
expect(
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
import type { ServerCapabilityState } from "@server/shared/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
export type VoiceReadinessMode = "dictation" | "voice";
export function getServerCapabilities(params: {
serverInfo: DaemonServerInfo | null | undefined;
}): DaemonServerInfo["capabilities"] | null {
const capabilities = params.serverInfo?.capabilities;
if (!capabilities) {
return null;
}
return capabilities;
}
export function getVoiceReadinessState(params: {
serverInfo: DaemonServerInfo | null | undefined;
mode: VoiceReadinessMode;
}): ServerCapabilityState | null {
const capabilities = getServerCapabilities({ serverInfo: params.serverInfo });
const voice = capabilities?.voice;
if (!voice) {
return null;
}
if (params.mode === "dictation") {
return voice.dictation;
}
return voice.voice;
}
export function resolveVoiceUnavailableMessage(params: {
serverInfo: DaemonServerInfo | null | undefined;
mode: VoiceReadinessMode;
}): string | null {
const readiness = getVoiceReadinessState({
serverInfo: params.serverInfo,
mode: params.mode,
});
if (!readiness) {
return null;
}
if (readiness.enabled && readiness.reason.trim().length === 0) {
return null;
}
const message = readiness.reason.trim();
if (message.length > 0) {
return message;
}
return null;
}

View File

@@ -17,6 +17,18 @@ type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
function toTauriOutgoingMessage(
data: string | Uint8Array | ArrayBuffer
): string | number[] {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Array.from(new Uint8Array(data));
}
return Array.from(data);
}
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
@@ -49,7 +61,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const pendingSends: string[] = [];
const pendingSends: Array<string | number[]> = [];
let closeRequested: { code?: number; reason?: string } | null = null;
const emitOpen = () => {
@@ -131,6 +143,10 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
emitMessage({ data: msg.data });
return;
}
if (msg.type === "Binary") {
emitMessage({ data: new Uint8Array(msg.data) });
return;
}
if (msg.type === "Close") {
emitClose(msg.data ?? undefined);
return;
@@ -168,11 +184,12 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const transport: DaemonTransport = {
send: (data) => {
if (disposed) return;
const outgoing = toTauriOutgoingMessage(data);
if (!ws) {
pendingSends.push(data);
pendingSends.push(outgoing);
return;
}
void ws.send(data).catch((error) => emitError(error));
void ws.send(outgoing).catch((error) => emitError(error));
},
close: (code?: number, reason?: string) => {
if (disposed) return;

View File

@@ -1,5 +1,6 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
@@ -116,15 +117,10 @@ function connectAndProbe(
unsubscribeStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown };
if (payload?.status !== "server_info") return;
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
if (!raw) return;
serverId = raw;
hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null;
if (hostname && hostname.length === 0) {
hostname = null;
}
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
serverId = payload.serverId;
hostname = payload.hostname;
maybeFinishOk();
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.3",
"version": "0.1.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -22,8 +22,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.3",
"@getpaseo/server": "0.1.3",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,9 +1,9 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type {
DaemonClient,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentStreamEventPayload,
AgentTimelineItem,
} from '@getpaseo/server'
@@ -131,37 +131,19 @@ export async function runAttachCommand(
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
console.log(`(Press Ctrl+C to detach)\n`)
// Get existing output from snapshot
const snapshotPromise = new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), 5000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
// Print recent events from snapshot
for (const e of message.payload.events) {
printStreamEvent(e.event)
}
resolve()
})
})
// Initialize agent to trigger snapshot
// Print existing output from timeline fetch.
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
})
for (const item of timelineItems) {
printTimelineItem(item)
}
} catch (error) {
console.warn('Warning: failed to fetch existing timeline', error)
}
// Wait for snapshot
await snapshotPromise
// Subscribe to new events
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage

View File

@@ -1,10 +1,10 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type {
DaemonClient,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentTimelineItem,
} from '@getpaseo/server'
import { curateAgentActivity } from '@getpaseo/server'
@@ -54,19 +54,6 @@ function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
}
}
/**
* Extract timeline items from an agent_stream_snapshot message
*/
function extractTimelineFromSnapshot(message: AgentStreamSnapshotMessage): AgentTimelineItem[] {
const items: AgentTimelineItem[] = []
for (const e of message.payload.events) {
if (e.event.type === 'timeline') {
items.push(e.event.item)
}
}
return items
}
export async function runLogsCommand(
id: string,
options: AgentLogsOptions,
@@ -112,32 +99,12 @@ export async function runLogsCommand(
return
}
// For non-follow mode, initialize the agent to get timeline snapshot
// Set up handler for timeline events before initializing
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
// Fetch timeline directly via cursor RPC.
let timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue to collect from queue
}
// Get timeline from snapshot
let timelineItems = await snapshotPromise
// Apply filter
if (options.filter) {
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
@@ -179,31 +146,12 @@ async function runFollowMode(
const DEFAULT_FOLLOW_TAIL = 10
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
// First, get existing timeline
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== agentId) return
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
// First, get existing timeline.
let existingItems = await fetchProjectedTimelineItems({
client,
agentId,
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(agentId)
} catch {
// Agent might already be initialized
}
// Get existing timeline
let existingItems = await snapshotPromise
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))

View File

@@ -37,6 +37,14 @@ export interface AgentModeOptions extends CommandOptions {
list?: boolean
}
const missingModeError = (): CommandError => ({
code: 'MISSING_ARGUMENT',
message: 'Mode argument required unless --list is specified',
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
})
// This command returns two different data shapes (set result vs mode list).
// Keep `any` here to match the existing output wrapper generic contract.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AgentModeResult = AnyCommandResult<any>
@@ -46,32 +54,17 @@ export async function runModeCommand(
options: AgentModeOptions,
_command: Command
): Promise<AgentModeResult> {
const normalizedMode = mode?.trim()
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!options.list && !mode) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Mode argument required unless --list is specified',
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
}
throw error
if (!options.list && !normalizedMode) {
throw missingModeError()
}
let client
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
const agent = await client.fetchAgent(id)
if (!agent) {
const error: CommandError = {
@@ -87,8 +80,6 @@ export async function runModeCommand(
// List available modes for this agent
const availableModes = agent.availableModes ?? []
await client.close()
const items: AgentMode[] = availableModes.map((m) => ({
id: m.id,
label: m.label,
@@ -100,32 +91,48 @@ export async function runModeCommand(
data: items,
schema: modeListSchema,
}
} else {
// Set the agent mode
await client.setAgentMode(resolvedId, mode!)
}
await client.close()
if (!normalizedMode) {
throw missingModeError()
}
return {
type: 'single',
data: {
agentId: resolvedId.slice(0, 7),
mode: mode!,
},
schema: setModeSchema,
}
// Set the agent mode
await client.setAgentMode(resolvedId, normalizedMode)
return {
type: 'single',
data: {
agentId: resolvedId.slice(0, 7),
mode: normalizedMode,
},
schema: setModeSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
if (!client) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'MODE_OPERATION_FAILED',
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
}
throw error
} finally {
if (client) {
await client.close().catch(() => {})
}
}
}

View File

@@ -169,12 +169,20 @@ function sleep(ms: number): Promise<void> {
})
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
}
return typeof error.code === 'string' ? error.code : undefined
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
}
@@ -187,7 +195,7 @@ function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
process.kill(pid, signal)
return true
} catch (err) {
const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return false
}

View File

@@ -12,6 +12,7 @@ import {
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from './local-daemon.js'
import { getErrorMessage } from '../../utils/errors.js'
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
@@ -73,9 +74,7 @@ export async function runStart(options: StartOptions): Promise<void> {
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? 'unknown'}).`))
console.log(chalk.dim(`Logs: ${startup.logPath}`))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(message))
process.exit(1)
exitWithError(getErrorMessage(err))
}
return
}
@@ -94,18 +93,15 @@ export async function runStart(options: StartOptions): Promise<void> {
logger = createRootLogger(persistedConfig)
config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(message))
process.exit(1)
exitWithError(getErrorMessage(err))
}
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>>
try {
daemon = await createPaseoDaemon(config, logger)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(`Failed to initialize daemon: ${message}`))
process.exit(1)
const message = getErrorMessage(err)
exitWithError(`Failed to initialize daemon: ${message}`)
}
let shuttingDown = false
@@ -140,8 +136,12 @@ export async function runStart(options: StartOptions): Promise<void> {
try {
await daemon.start()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(`Failed to start daemon: ${message}`))
process.exit(1)
const message = getErrorMessage(err)
exitWithError(`Failed to start daemon: ${message}`)
}
}
function exitWithError(message: string): never {
console.error(chalk.red(message))
process.exit(1)
}

View File

@@ -3,11 +3,9 @@ import { Command } from 'commander'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import {
ensureLocalSpeechModels,
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type LocalSpeechModelId,
type CliConfigOverrides,
type PersistedConfig,
} from '@getpaseo/server'
@@ -154,7 +152,7 @@ async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boo
}
const answer = await confirm({
message: 'Enable voice features? (downloads local STT/TTS models now)',
message: 'Enable voice features? (downloads local STT/TTS models in background)',
active: 'Yes',
inactive: 'No',
initialValue: false,
@@ -172,221 +170,6 @@ type DownloadProgress = {
pct: number | null
}
type LocalModelDownloadProgress = {
modelId: string | null
pct: number | null
}
type LocalSpeechDownloadLogger = {
child: (_bindings: Record<string, unknown>) => LocalSpeechDownloadLogger
info: (obj?: unknown, msg?: string) => void
error: (_obj?: unknown, _msg?: string) => void
}
type LocalSpeechDownloadEvent =
| {
type: 'progress'
progress: LocalModelDownloadProgress
}
| {
type: 'phase'
phase: 'extracting' | 'verifying' | 'finalizing' | 'completed'
}
function resolveRequiredLocalModelIds(config: ReturnType<typeof loadConfig>): LocalSpeechModelId[] {
const providers = config.speech?.providers
const local = config.speech?.local
if (!providers || !local) {
return []
}
const ids = new Set<LocalSpeechModelId>()
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === 'local') {
ids.add(local.models.dictationStt)
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === 'local') {
ids.add(local.models.voiceStt)
}
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === 'local') {
ids.add(local.models.voiceTts)
}
return Array.from(ids)
}
function parseLocalModelDownloadProgress(payload: unknown): LocalModelDownloadProgress | null {
if (!payload || typeof payload !== 'object') {
return null
}
const value = payload as Record<string, unknown>
const modelId = typeof value.modelId === 'string' ? value.modelId : null
const pctRaw = value.pct
const pct = typeof pctRaw === 'number' && Number.isFinite(pctRaw) ? Math.max(0, Math.min(100, Math.floor(pctRaw))) : null
return {
modelId,
pct,
}
}
function renderLocalModelProgress(params: {
modelId: LocalSpeechModelId
modelIndex: number
modelCount: number
pct: number | null
}): string {
const prefix = `Downloading speech model ${params.modelIndex}/${params.modelCount}: ${params.modelId}`
if (params.pct === null) {
return `${prefix}...`
}
return `${prefix} (${params.pct}%)`
}
function createLocalSpeechDownloadLogger(
onEvent: (event: LocalSpeechDownloadEvent) => void
): LocalSpeechDownloadLogger {
const logger: LocalSpeechDownloadLogger = {
child: () => logger,
info: (obj?: unknown, msg?: string) => {
if (msg === 'Downloading model artifact') {
const progress = parseLocalModelDownloadProgress(obj)
if (!progress) {
return
}
onEvent({
type: 'progress',
progress,
})
return
}
if (msg === 'Extracting model archive') {
onEvent({ type: 'phase', phase: 'extracting' })
return
}
if (msg === 'Verifying downloaded model files') {
onEvent({ type: 'phase', phase: 'verifying' })
return
}
if (msg === 'Finalizing model artifacts') {
onEvent({ type: 'phase', phase: 'finalizing' })
return
}
if (msg === 'Model download completed') {
onEvent({ type: 'phase', phase: 'completed' })
return
}
},
error: () => {
// no-op: onboarding handles surfaced errors from ensureLocalSpeechModels.
},
}
return logger
}
async function prepareLocalSpeechModelsBeforeStart(args: {
config: ReturnType<typeof loadConfig>
richUi: boolean
}): Promise<void> {
const local = args.config.speech?.local
const modelIds = resolveRequiredLocalModelIds(args.config)
if (!local || modelIds.length === 0) {
return
}
if (local.autoDownload === false) {
log.warn('Local speech model auto-download is disabled. Voice may be unavailable until models are installed.')
return
}
const modelList = modelIds.join(', ')
const modelCount = modelIds.length
const downloadSpinner = args.richUi ? spinner() : null
let lastPlainStatus = ''
const emitStatus = (status: string): void => {
if (downloadSpinner) {
downloadSpinner.message(status)
return
}
if (status === lastPlainStatus) {
return
}
console.log(status)
lastPlainStatus = status
}
if (downloadSpinner) {
downloadSpinner.start(`Preparing local speech models (${modelCount})...`)
} else {
log.message(`Preparing local speech models (${modelCount}): ${modelList}`)
}
try {
for (const [index, modelId] of modelIds.entries()) {
const modelIndex = index + 1
emitStatus(`Checking speech model ${modelIndex}/${modelCount}: ${modelId}`)
const perModelLogger = createLocalSpeechDownloadLogger((event) => {
if (event.type === 'progress') {
const progress = event.progress
if (progress.modelId && progress.modelId !== modelId) {
return
}
emitStatus(
renderLocalModelProgress({
modelId,
modelIndex,
modelCount,
pct: progress.pct,
})
)
return
}
if (event.phase === 'extracting') {
emitStatus(`Extracting speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
if (event.phase === 'verifying') {
emitStatus(`Verifying speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
if (event.phase === 'finalizing') {
emitStatus(`Finalizing speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
})
await ensureLocalSpeechModels({
modelsDir: local.modelsDir,
modelIds: [modelId],
autoDownload: true,
logger: perModelLogger as any,
})
emitStatus(`Speech model ready ${modelIndex}/${modelCount}: ${modelId}`)
}
if (downloadSpinner) {
downloadSpinner.stop(`Local speech models ready (${modelCount})`)
} else {
log.message(`Local speech models ready (${modelCount}): ${modelList}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (downloadSpinner) {
downloadSpinner.error(`Failed to prepare local speech models: ${message}`)
} else {
log.error(`Failed to prepare local speech models: ${message}`)
}
throw error
}
}
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split('\n').filter(Boolean)
@@ -583,19 +366,10 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const voiceStatus = voiceEnabled
? 'Voice features enabled. Local speech models will be downloaded if missing.'
: 'Voice features disabled. Local speech models will not be downloaded now.'
? 'Voice features enabled. Local speech models will download in the background if missing.'
: 'Voice features disabled. Local speech models will not be downloaded.'
log.message(voiceStatus)
try {
await prepareLocalSpeechModelsBeforeStart({
config,
richUi,
})
} catch {
process.exit(1)
}
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
const startSpinner = richUi ? spinner() : null

View File

@@ -1,4 +1,6 @@
import type { Command } from 'commander'
import { homedir } from 'node:os'
import { basename, join, sep } from 'node:path'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
@@ -21,9 +23,20 @@ function shortenPath(path: string): string {
/** Extract worktree name from path */
function extractWorktreeName(path: string): string {
// ~/.paseo/worktrees/<repo>/<name> -> name
const parts = path.split('/')
return parts[parts.length - 1] ?? path
return basename(path)
}
export function resolvePaseoHomePath(): string {
return process.env.PASEO_HOME ?? join(homedir(), '.paseo')
}
export function resolvePaseoWorktreesDir(): string {
return join(resolvePaseoHomePath(), 'worktrees')
}
function isAgentInManagedWorktree(agentCwd: string): boolean {
const worktreesDir = resolvePaseoWorktreesDir()
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep)
}
/** Schema for worktree ls output */
@@ -81,10 +94,7 @@ export async function runLsCommand(
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>()
for (const agent of agents) {
// Check if agent cwd is under ~/.paseo/worktrees/
const paseoHome = process.env.PASEO_HOME ?? (process.env.HOME + '/.paseo')
const worktreesDir = paseoHome + '/worktrees/'
if (agent.cwd.startsWith(worktreesDir)) {
if (isAgentInManagedWorktree(agent.cwd)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
}
}

View File

@@ -23,8 +23,9 @@ function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as {
readyState: number
send: (data: string) => void
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
binaryType?: string
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}

View File

@@ -0,0 +1,10 @@
/**
* Convert unknown thrown values into a user-safe error string.
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -0,0 +1,17 @@
import type { AgentTimelineItem, DaemonClient } from '@getpaseo/server'
type FetchProjectedTimelineItemsInput = {
client: DaemonClient
agentId: string
}
export async function fetchProjectedTimelineItems(
input: FetchProjectedTimelineItemsInput
): Promise<AgentTimelineItem[]> {
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
direction: 'tail',
limit: 0,
projection: 'projected',
})
return timeline.entries.map((entry) => entry.item)
}

View File

@@ -3,15 +3,15 @@
/**
* Phase 2: Daemon Command Tests
*
* Tests daemon commands - currently focused on error cases and help
* since daemon start may not be fully working yet.
* Tests daemon commands with an isolated PASEO_HOME.
*
* Tests:
* - daemon --help shows subcommands
* - daemon pair prints a local pairing link without requiring a running daemon
* - daemon status fails gracefully when daemon not running
* - daemon status --json outputs valid JSON (even for errors)
* - daemon status reports stopped when daemon not running
* - daemon status --json outputs valid JSON
* - daemon stop handles daemon not running gracefully
* - daemon restart starts the daemon and can be cleaned up
*/
import assert from 'node:assert'
@@ -24,7 +24,7 @@ $.verbose = false
console.log('=== Daemon Commands ===\n')
// Get random port that's definitely not in use (never 6767)
// Keep restart off default 6767 to avoid collisions with any existing daemon.
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
@@ -53,87 +53,60 @@ try {
console.log('✓ daemon pair prints local pairing URL\n')
}
// Test 3: daemon status fails gracefully when daemon not running
// Test 3: daemon status reports stopped when daemon not running
{
console.log('Test 3: daemon status fails gracefully when not running')
console.log('Test 3: daemon status reports stopped when not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// The error should mention something about daemon not running or connection
const output = result.stdout + result.stderr
const hasDaemonError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('running')
assert(hasDaemonError, 'error message should mention daemon/connect/running')
console.log('✓ daemon status fails gracefully when not running\n')
await $`PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
assert.strictEqual(result.exitCode, 0, 'status should succeed when daemon is stopped')
const output = result.stdout.toLowerCase()
assert(output.includes('status'), 'status table should include Status row')
assert(output.includes('stopped'), 'status should report stopped')
console.log('daemon status reports stopped when not running\n')
}
// Test 4: daemon status --json outputs valid JSON (even for errors)
// Test 4: daemon status --json outputs valid JSON
{
console.log('Test 4: daemon status --json outputs JSON')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON
const output = result.stdout.trim()
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ daemon status --json outputs valid JSON\n')
} catch {
// If stdout is empty, check if stderr has the error (acceptable for now)
console.log('✓ daemon status --json handled error (output may be in stderr)\n')
}
} else {
// Empty stdout is acceptable if error is in stderr
console.log('✓ daemon status --json handled error gracefully\n')
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
const status = JSON.parse(result.stdout)
assert.strictEqual(status.status, 'stopped', 'json status should report stopped')
assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home')
console.log('✓ daemon status --json outputs valid JSON\n')
}
// Test 5: daemon stop handles daemon not running gracefully
{
console.log('Test 5: daemon stop handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon not running (idempotent)
// OR it should fail gracefully with a clear message
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon is not running (idempotent).
assert.strictEqual(result.exitCode, 0, 'stop should succeed when daemon not running')
const output = result.stdout + result.stderr
if (result.exitCode === 0) {
// If it succeeds, it should mention the daemon wasn't running
const mentionsNotRunning =
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('was not running')
assert(mentionsNotRunning, 'success output should mention daemon was not running')
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
} else {
// If it fails, error should be clear
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('not running')
assert(hasError, 'error message should be clear about daemon state')
console.log('✓ daemon stop fails gracefully when daemon not running\n')
}
const mentionsNotRunning =
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('was not running')
assert(mentionsNotRunning, 'output should mention daemon was not running')
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
}
// Test 6: daemon restart fails when daemon not running
// Test 6: daemon restart starts daemon and can be stopped
{
console.log('Test 6: daemon restart fails when daemon not running')
console.log('Test 6: daemon restart starts daemon and can be stopped')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow()
// Restart should fail when daemon not running (can't restart something that's not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('connect')
assert(hasError, 'error message should mention daemon state')
console.log('✓ daemon restart fails appropriately when daemon not running\n')
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')
assert(result.stdout.toLowerCase().includes('restarted'), 'output should report restart')
const cleanup = await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --force`.nothrow()
assert.strictEqual(cleanup.exitCode, 0, 'cleanup stop should succeed after restart')
console.log('✓ daemon restart starts and stop cleanup succeeds\n')
}
} finally {
// Best-effort daemon cleanup in case assertions fail before explicit stop.
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --force`.nothrow()
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}

View File

@@ -125,12 +125,10 @@ try {
// Should fail because mode is required unless --list is specified
assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('mode') ||
output.toLowerCase().includes('daemon') // If daemon error comes first, that's also valid
assert(hasError, 'error should mention missing mode or connection issue')
assert(
output.includes('Mode argument required unless --list is specified'),
'error should mention missing mode argument'
)
console.log('✓ agent mode requires mode argument when not using --list\n')
}
} finally {

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env npx tsx
/**
* Phase 18: Local daemon utility tests.
*
* Tests pure helpers that do not require a running daemon.
*/
import assert from 'node:assert'
import { resolveTcpHostFromListen } from '../src/commands/daemon/local-daemon.js'
console.log('=== Local Daemon Utility Helpers ===\n')
{
console.log('Test 1: resolves numeric listen values to localhost host:port')
assert.strictEqual(resolveTcpHostFromListen('6767'), '127.0.0.1:6767')
assert.strictEqual(resolveTcpHostFromListen(' 7777 '), '127.0.0.1:7777')
console.log('✓ resolves numeric listen values\n')
}
{
console.log('Test 2: preserves explicit host:port listen values')
assert.strictEqual(resolveTcpHostFromListen('localhost:6767'), 'localhost:6767')
assert.strictEqual(resolveTcpHostFromListen('0.0.0.0:8080'), '0.0.0.0:8080')
console.log('✓ preserves explicit host:port values\n')
}
{
console.log('Test 3: rejects unix socket listen values')
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null)
console.log('✓ rejects unix socket listen values\n')
}
{
console.log('Test 4: rejects empty and non-host listen values')
assert.strictEqual(resolveTcpHostFromListen(''), null)
assert.strictEqual(resolveTcpHostFromListen(' '), null)
assert.strictEqual(resolveTcpHostFromListen('localhost'), null)
console.log('✓ rejects empty and non-host listen values\n')
}
console.log('=== All local daemon utility tests passed ===')

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { getErrorMessage } from '../src/utils/errors.js'
console.log('=== Error Utils ===\n')
{
console.log('Test 1: returns Error.message for Error instances')
assert.strictEqual(getErrorMessage(new Error('boom')), 'boom')
console.log('✓ returns Error.message\n')
}
{
console.log('Test 2: stringifies non-Error values')
assert.strictEqual(getErrorMessage('plain string'), 'plain string')
assert.strictEqual(getErrorMessage(42), '42')
assert.strictEqual(getErrorMessage(null), 'null')
console.log('✓ stringifies non-Error values\n')
}
console.log('=== All error utility tests passed ===')

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from '../src/commands/worktree/ls.js'
console.log('=== Worktree LS Path Helper Tests ===\n')
const originalPaseoHome = process.env.PASEO_HOME
try {
{
console.log('Test 1: resolves explicit PASEO_HOME when set')
process.env.PASEO_HOME = '/tmp/paseo-explicit-home'
assert.strictEqual(resolvePaseoHomePath(), '/tmp/paseo-explicit-home')
assert.strictEqual(resolvePaseoWorktreesDir(), '/tmp/paseo-explicit-home/worktrees')
console.log('\u2713 explicit PASEO_HOME is respected\n')
}
{
console.log('Test 2: falls back to homedir/.paseo when PASEO_HOME is unset')
delete process.env.PASEO_HOME
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), '.paseo'))
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), '.paseo', 'worktrees'))
console.log('\u2713 fallback home path is derived from os.homedir()\n')
}
} finally {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME
} else {
process.env.PASEO_HOME = originalPaseoHome
}
}
console.log('=== All worktree ls path helper tests passed ===')

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.3",
"version": "0.1.4",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RelayDurableObject } from "./cloudflare-adapter.js";
type MockSocket = WebSocket & {
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
serializeAttachment: ReturnType<typeof vi.fn>;
deserializeAttachment: ReturnType<typeof vi.fn>;
};
function createMockSocket(attachment: unknown = null): MockSocket {
let storedAttachment = attachment;
return {
send: vi.fn(),
close: vi.fn(),
serializeAttachment: vi.fn((value: unknown) => {
storedAttachment = value;
}),
deserializeAttachment: vi.fn(() => storedAttachment),
} as unknown as MockSocket;
}
function createMockState() {
const socketsByTag = new Map<string, WebSocket[]>();
const state = {
acceptWebSocket: vi.fn(),
getWebSockets: vi.fn((tag?: string): WebSocket[] => {
if (!tag) {
const out: WebSocket[] = [];
for (const sockets of socketsByTag.values()) out.push(...sockets);
return out;
}
return socketsByTag.get(tag) ?? [];
}),
};
return {
state,
setTagSockets: (tag: string, sockets: WebSocket[]) => {
socketsByTag.set(tag, sockets);
},
};
}
describe("RelayDurableObject control nudge/reset behavior", () => {
afterEach(() => {
vi.useRealTimers();
});
it("does not nudge or reset control after the client already disconnected", () => {
vi.useFakeTimers();
const clientId = "clt_stale_timer";
const control = createMockSocket();
const { state, setTagSockets } = createMockState();
setTagSockets("server-control", [control]);
setTagSockets("client", []);
setTagSockets(`client:${clientId}`, []);
setTagSockets(`server:${clientId}`, []);
const relay = new RelayDurableObject(state as any);
(relay as any).nudgeOrResetControlForClient(clientId);
vi.advanceTimersByTime(15_000);
expect(control.send).not.toHaveBeenCalled();
expect(control.close).not.toHaveBeenCalled();
});
it("resets control when the client remains connected but no server-data socket appears", () => {
vi.useFakeTimers();
const clientId = "clt_waiting_for_daemon";
const control = createMockSocket();
const client = createMockSocket({
role: "client",
clientId,
serverId: "srv_test",
createdAt: Date.now(),
});
const { state, setTagSockets } = createMockState();
setTagSockets("server-control", [control]);
setTagSockets("client", [client]);
setTagSockets(`client:${clientId}`, [client]);
setTagSockets(`server:${clientId}`, []);
const relay = new RelayDurableObject(state as any);
(relay as any).nudgeOrResetControlForClient(clientId);
vi.advanceTimersByTime(10_000);
expect(control.send).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5_000);
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive");
});
});

View File

@@ -83,6 +83,14 @@ export class RelayDurableObject {
}
}
private hasClientSocket(clientId: string): boolean {
try {
return this.state.getWebSockets(`client:${clientId}`).length > 0;
} catch {
return false;
}
}
private nudgeOrResetControlForClient(clientId: string): void {
// If the daemon's control WS becomes half-open, the DO can't reliably detect it via ws.send errors
// (Cloudflare may accept writes even if the other side is no longer reading).
@@ -94,12 +102,14 @@ export class RelayDurableObject {
const secondDelayMs = 5_000;
setTimeout(() => {
if (!this.hasClientSocket(clientId)) return;
if (this.hasServerDataSocket(clientId)) return;
// First nudge: send a full sync list.
this.notifyControls({ type: "sync", clientIds: this.listConnectedClientIds() });
setTimeout(() => {
if (!this.hasClientSocket(clientId)) return;
if (this.hasServerDataSocket(clientId)) return;
// Still nothing: assume control is stuck and force a reconnect.

View File

@@ -12,6 +12,9 @@ import {
decrypt,
} from "./crypto.js";
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -46,7 +49,36 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
}
describe("E2E Relay with E2EE", () => {
async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server`;
const opened = await new Promise<boolean>((resolve) => {
const ws = new WebSocket(probeUrl);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (opened) {
return;
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
(shouldRunRelayE2e ? describe : describe.skip)("E2E Relay with E2EE", () => {
let relayPort: number;
let relayProcess: ChildProcess | null = null;
@@ -54,7 +86,17 @@ describe("E2E Relay with E2EE", () => {
relayPort = await getAvailablePort();
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
[
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
cwd: process.cwd(),
env: { ...process.env },
@@ -79,6 +121,7 @@ describe("E2E Relay with E2EE", () => {
});
await waitForServer(relayPort, 30000);
await waitForRelayWebSocketReady(relayPort, 60000);
});
afterAll(async () => {
@@ -88,7 +131,7 @@ describe("E2E Relay with E2EE", () => {
}
});
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 20_000 }, async () => {
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
const serverId = "test-session-" + Date.now();
const clientId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
@@ -255,7 +298,7 @@ describe("E2E Relay with E2EE", () => {
clientWs.close();
});
it("relay only sees opaque bytes after handshake", async () => {
it("relay only sees opaque bytes after handshake", { timeout: 90_000 }, async () => {
const serverId = "opaque-test-" + Date.now();
const clientId = "clt_opaque_" + Date.now() + "_" + Math.random().toString(36).slice(2);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.3",
"version": "0.1.4",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -53,7 +53,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/relay": "0.1.3",
"@getpaseo/relay": "0.1.4",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",

View File

@@ -0,0 +1,204 @@
import {
createClientChannel,
type EncryptedChannel,
type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee";
import type {
DaemonTransport,
DaemonTransportFactory,
TransportLogger,
} from "./daemon-client-transport-types.js";
import {
extractRelayMessageData,
normalizeTransportPayload,
} from "./daemon-client-transport-utils.js";
type OpenHandler = () => void;
type CloseHandler = (event?: unknown) => void;
type ErrorHandler = (event?: unknown) => void;
type MessageHandler = (data: unknown) => void;
export function createRelayE2eeTransportFactory(args: {
baseFactory: DaemonTransportFactory;
daemonPublicKeyB64: string;
logger: TransportLogger;
}): DaemonTransportFactory {
return ({ url, headers }) => {
const base = args.baseFactory({ url, headers });
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
};
}
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger
): DaemonTransport {
let channel: EncryptedChannel | null = null;
let opened = false;
let closed = false;
const openHandlers = new Set<OpenHandler>();
const closeHandlers = new Set<CloseHandler>();
const errorHandlers = new Set<ErrorHandler>();
const messageHandlers = new Set<MessageHandler>();
const emitOpen = () => {
if (opened || closed) {
return;
}
opened = true;
emitHandlers(openHandlers);
};
const emitClose = (event?: unknown) => {
if (closed) {
return;
}
closed = true;
emitHandlers(closeHandlers, event);
};
const emitError = (event?: unknown) => {
if (closed) {
return;
}
emitHandlers(errorHandlers, event);
};
const emitMessage = (data: unknown) => {
if (closed) {
return;
}
emitHandlers(messageHandlers, data);
};
const relayTransport: RelayTransport = {
send: (data) => {
if (typeof data === "string") {
base.send(data);
return;
}
if (ArrayBuffer.isView(data)) {
base.send(normalizeTransportPayload(data));
return;
}
if (data instanceof ArrayBuffer) {
base.send(data);
return;
}
base.send(String(data));
},
close: (code?: number, reason?: string) => base.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
const startHandshake = async () => {
try {
channel = await createClientChannel(relayTransport, daemonPublicKeyB64, {
onopen: emitOpen,
onmessage: (data) => emitMessage(data),
onclose: (code, reason) => emitClose({ code, reason }),
onerror: (error) => emitError(error),
});
} catch (error) {
logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed");
emitError(error);
// Browser WebSocket.close only accepts 1000 or 3000-4999.
// Use an app-defined code so this path works in browser and Node runtimes.
base.close(4001, "E2EE handshake failed");
}
};
base.onOpen(() => {
void startHandshake();
});
base.onMessage((event) => {
relayTransport.onmessage?.(extractRelayMessageData(event));
});
base.onClose((event) => {
const record = event as { code?: number; reason?: string } | undefined;
relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? "");
emitClose(event);
});
base.onError((event) => {
relayTransport.onerror?.(
event instanceof Error ? event : new Error(String(event))
);
emitError(event);
});
return {
send: (data) => {
if (!channel) {
throw new Error("Encrypted channel not ready");
}
void channel.send(normalizeTransportPayload(data)).catch((error) => {
emitError(error);
});
},
close: (code?: number, reason?: string) => {
if (channel) {
channel.close(code, reason);
} else {
base.close(code, reason);
}
emitClose({ code, reason });
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
if (opened) {
invokeHandler(handler);
}
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
if (closed) {
invokeHandler(handler);
}
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
}
function emitHandlers<TArgs extends unknown[]>(
handlers: Set<(...args: TArgs) => void>,
...args: TArgs
) {
for (const handler of handlers) {
invokeHandler(handler, ...args);
}
}
function invokeHandler<TArgs extends unknown[]>(
handler: (...args: TArgs) => void,
...args: TArgs
) {
try {
handler(...args);
} catch {
// no-op
}
}
function normalizeTransportError(error: unknown): Record<string, string> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
...(typeof error.stack === "string" ? { stack: error.stack } : {}),
};
}
return { message: String(error) };
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, test, vi } from "vitest";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
function createChunk(input: {
streamId: number;
offset: number;
data: string;
}): TerminalStreamChunk {
const bytes = new TextEncoder().encode(input.data);
return {
streamId: input.streamId,
offset: input.offset,
endOffset: input.offset + bytes.byteLength,
replay: false,
data: bytes,
};
}
describe("TerminalStreamManager", () => {
test("buffers chunks and flushes with ack when handler subscribes", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
const seen: string[] = [];
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.subscribe({
streamId: 7,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["hello"]);
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
});
test("does not ack when every handler throws", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 11,
handler: () => {
throw new Error("boom");
},
});
manager.receiveChunk({
chunk: createChunk({ streamId: 11, offset: 0, data: "x" }),
});
expect(sendAck).not.toHaveBeenCalled();
});
test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({
sendAck,
maxBufferedChunks: 2,
maxBufferedBytes: 1024,
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 0, data: "A" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 1, data: "B" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
const seen: string[] = [];
manager.subscribe({
streamId: 5,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 13,
handler: () => {
// no-op
},
});
manager.noteAck({ streamId: 13, offset: 10 });
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 11, data: "z" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 });
});
});

View File

@@ -0,0 +1,180 @@
export type TerminalStreamChunk = {
streamId: number;
offset: number;
endOffset: number;
replay: boolean;
data: Uint8Array;
};
type TerminalChunkHandler = (chunk: TerminalStreamChunk) => void;
type BufferedTerminalStreamQueue = {
chunks: TerminalStreamChunk[];
bytes: number;
};
type TerminalStreamAck = {
streamId: number;
offset: number;
};
export type TerminalStreamManagerConfig = {
sendAck: (ack: TerminalStreamAck) => void;
maxBufferedChunks?: number;
maxBufferedBytes?: number;
};
const DEFAULT_MAX_BUFFERED_CHUNKS = 2048;
const DEFAULT_MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
export class TerminalStreamManager {
private readonly handlers: Map<number, Set<TerminalChunkHandler>> = new Map();
private readonly bufferedChunks: Map<number, BufferedTerminalStreamQueue> = new Map();
private readonly ackOffsets: Map<number, number> = new Map();
private readonly maxBufferedChunks: number;
private readonly maxBufferedBytes: number;
constructor(private readonly config: TerminalStreamManagerConfig) {
this.maxBufferedChunks =
config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
this.maxBufferedBytes =
config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
}
clearAll(): void {
this.handlers.clear();
this.bufferedChunks.clear();
this.ackOffsets.clear();
}
clearStream(input: { streamId: number }): void {
this.handlers.delete(input.streamId);
this.bufferedChunks.delete(input.streamId);
this.ackOffsets.delete(input.streamId);
}
subscribe(input: {
streamId: number;
handler: TerminalChunkHandler;
}): () => void {
const { streamId, handler } = input;
if (!this.handlers.has(streamId)) {
this.handlers.set(streamId, new Set());
}
const streamHandlers = this.handlers.get(streamId)!;
streamHandlers.add(handler);
this.flushBufferedChunks({ streamId, handler });
return () => {
streamHandlers.delete(handler);
if (streamHandlers.size === 0) {
this.handlers.delete(streamId);
}
};
}
receiveChunk(input: { chunk: TerminalStreamChunk }): void {
const { chunk } = input;
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
return;
}
let delivered = false;
for (const handler of streamHandlers) {
try {
handler(chunk);
delivered = true;
} catch {
// no-op
}
}
if (delivered) {
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
}
}
noteAck(input: TerminalStreamAck): void {
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedOffset > previousAck) {
this.ackOffsets.set(input.streamId, normalizedOffset);
}
}
private flushBufferedChunks(input: {
streamId: number;
handler: TerminalChunkHandler;
}): void {
const buffered = this.bufferedChunks.get(input.streamId);
if (!buffered || buffered.chunks.length === 0) {
return;
}
for (const chunk of buffered.chunks) {
try {
input.handler(chunk);
this.maybeAckChunk({
streamId: input.streamId,
endOffset: chunk.endOffset,
});
} catch {
// no-op
}
}
this.bufferedChunks.delete(input.streamId);
}
private bufferChunk(input: { chunk: TerminalStreamChunk }): void {
const queue = this.bufferedChunks.get(input.chunk.streamId) ?? {
chunks: [],
bytes: 0,
};
queue.chunks.push(input.chunk);
queue.bytes += input.chunk.data.byteLength;
while (
queue.chunks.length > this.maxBufferedChunks ||
queue.bytes > this.maxBufferedBytes
) {
const removed = queue.chunks.shift();
if (!removed) {
break;
}
queue.bytes -= removed.data.byteLength;
if (queue.bytes < 0) {
queue.bytes = 0;
}
}
this.bufferedChunks.set(input.chunk.streamId, queue);
}
private maybeAckChunk(input: { streamId: number; endOffset: number }): void {
if (!Number.isFinite(input.endOffset) || input.endOffset < 0) {
return;
}
const normalizedEndOffset = Math.floor(input.endOffset);
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedEndOffset <= previousAck) {
return;
}
this.ackOffsets.set(input.streamId, normalizedEndOffset);
try {
this.config.sendAck({
streamId: input.streamId,
offset: normalizedEndOffset,
});
} catch {
// no-op
}
}
}

View File

@@ -0,0 +1,38 @@
export type DaemonTransport = {
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
onMessage: (handler: (data: unknown) => void) => () => void;
onOpen: (handler: () => void) => () => void;
onClose: (handler: (event?: unknown) => void) => () => void;
onError: (handler: (event?: unknown) => void) => () => void;
};
export type DaemonTransportFactory = (options: {
url: string;
headers?: Record<string, string>;
}) => DaemonTransport;
export type WebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> }
) => WebSocketLike;
export type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
export interface TransportLogger {
warn(obj: object, msg?: string): void;
}

View File

@@ -0,0 +1,127 @@
export function copyArrayBufferViewToBuffer(data: ArrayBufferView): ArrayBuffer {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
}
export function normalizeTransportPayload(
data: string | Uint8Array | ArrayBuffer
): string | ArrayBuffer {
if (typeof data === "string" || data instanceof ArrayBuffer) {
return data;
}
return copyArrayBufferViewToBuffer(data);
}
export function extractRelayMessageData(event: unknown): string | ArrayBuffer {
const raw =
event && typeof event === "object" && "data" in event
? (event as { data: unknown }).data
: event;
if (typeof raw === "string") return raw;
if (raw instanceof ArrayBuffer) return raw;
if (ArrayBuffer.isView(raw)) {
return copyArrayBufferViewToBuffer(raw);
}
return String(raw ?? "");
}
export function describeTransportClose(event?: unknown): string {
if (!event) {
return "Transport closed";
}
if (event instanceof Error) {
return event.message;
}
if (typeof event === "string") {
return event;
}
if (typeof event === "object") {
const record = event as { reason?: unknown; message?: unknown; code?: unknown };
if (typeof record.reason === "string" && record.reason.trim().length > 0) {
return record.reason.trim();
}
if (typeof record.message === "string" && record.message.trim().length > 0) {
return record.message.trim();
}
if (typeof record.code === "number") {
return `Transport closed (code ${record.code})`;
}
}
return "Transport closed";
}
export function describeTransportError(event?: unknown): string {
if (!event) {
return "Transport error";
}
if (event instanceof Error) {
return event.message;
}
if (typeof event === "string") {
return event;
}
if (typeof event === "object") {
const record = event as { message?: unknown };
if (typeof record.message === "string" && record.message.trim().length > 0) {
return record.message.trim();
}
}
return "Transport error";
}
export function safeRandomId(): string {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function decodeMessageData(data: unknown): string | null {
if (data === null || data === undefined) {
return null;
}
if (typeof data === "string") {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
if (typeof Buffer !== "undefined") {
return Buffer.from(data).toString("utf8");
}
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(data);
}
}
if (ArrayBuffer.isView(data)) {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (typeof Buffer !== "undefined") {
return Buffer.from(view).toString("utf8");
}
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(view);
}
}
if (typeof (data as { toString?: () => string }).toString === "function") {
return (data as { toString: () => string }).toString();
}
return null;
}
export function encodeUtf8String(value: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(value);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(value, "utf8"));
}
const out = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
out[i] = value.charCodeAt(i) & 0xff;
}
return out;
}

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