Compare commits

..

62 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
Mohamed Boudra
6f6677fc7a chore(release): cut 0.1.3 and add release playbook 2026-02-12 18:22:33 +07:00
Mohamed Boudra
972895d855 Reduce daemon startup noise and normalize tool-call mappers 2026-02-12 18:16:36 +07:00
Mohamed Boudra
50acf2dd44 Merge branch 'feature/cli-output-schema' 2026-02-12 18:04:41 +07:00
Mohamed Boudra
cb2e4aacfe feat(cli): add --output-schema flag for structured agent output 2026-02-12 18:04:39 +07:00
Mohamed Boudra
117bbfe2f4 feat: add provider availability detection and normalize legacy "default" model IDs 2026-02-12 18:04:21 +07:00
Mohamed Boudra
8bdd083512 Merge branch 'main' of github.com:getpaseo/paseo 2026-02-12 18:04:13 +07:00
Mohamed Boudra
f79468b793 Refactor Claude/Codex tool-call parsing to provider-local Zod passes (#25)
* refactor claude/codex tool call parsing to provider-local zod passes

* tighten provider tool-call schemas and remove alias probing

* refactor provider tool-call mappers to strict two-pass zod flow

* test: lock opencode mapper against cross-provider speak normalization

* refactor: enforce schema-only two-pass tool-call normalization
2026-02-12 19:02:11 +08:00
Mohamed Boudra
018edef3bb feat: add onboarding command and migrate git diff to checkout API 2026-02-12 17:00:35 +07:00
Mohamed Boudra
44c32fb8fb website: update Mac download link to v0.1.2 direct DMG 2026-02-12 15:31:11 +07:00
Mohamed Boudra
e542854ae0 website: add privacy policy page and footer link
Add a minimal privacy policy at /privacy that explains:
- Paseo is self-hosted, no data collection
- The relay uses end-to-end encryption and cannot read messages
- No analytics, tracking, or ads
- Third-party agent providers handle their own auth

Add footer link on home page styled as muted/subtle.
2026-02-12 14:20:32 +07:00
Mohamed Boudra
fba7c73c8b feat(cli): add agent update command for name and labels 2026-02-12 14:15:33 +07:00
Mohamed Boudra
f55d0cd25f Animate file explorer refresh icon while fetching 2026-02-12 14:08:30 +07:00
Mohamed Boudra
3319c2d40c Fix dev runner entry and sherpa TTS initialization 2026-02-12 14:05:46 +07:00
Mohamed Boudra
bb93fc2a61 Add refresh loading feedback in file explorer 2026-02-12 14:05:46 +07:00
Mohamed Boudra
0d0f2826d9 website: make install command primary hero CTA 2026-02-12 14:05:46 +07:00
Mohamed Boudra
d682c74469 Fix explorer tab fallback when checkout status is unresolved 2026-02-12 14:05:46 +07:00
Mohamed Boudra
f55e010a19 Hide Changes tab when not git repo and add refresh button to Files pane
- Hide Changes tab completely when isGit is false instead of just muting it
- Automatically switch to Files tab if user was on Changes when it becomes hidden
- Add refresh button next to sort button in Files pane header
- Refresh button refreshes root directory, expanded directories, and file preview if open
2026-02-12 14:05:46 +07:00
Mohamed Boudra
5f864a4dd7 Add speech-start grace period before voice interrupt 2026-02-12 14:05:46 +07:00
Mohamed Boudra
6053ee7c34 fix(cli): list all non-archived agents by default and drop ls --ui 2026-02-12 14:05:46 +07:00
Mohamed Boudra
3a303d2095 ci: fix desktop release version script regex literals 2026-02-11 14:47:01 +07:00
Mohamed Boudra
84079bfc36 ci: allow package reads and use npm ci for release build 2026-02-11 14:42:12 +07:00
210 changed files with 18320 additions and 5020 deletions

View File

@@ -20,6 +20,7 @@ jobs:
publish-tauri:
permissions:
contents: write
packages: read
runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
@@ -44,7 +45,7 @@ jobs:
targets: aarch64-apple-darwin
- name: Install JS dependencies
run: npm install --workspace=@getpaseo/app --workspace=@getpaseo/desktop --include-workspace-root
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -66,34 +67,42 @@ jobs:
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /(\"version\"\\s*:\\s*\")([^\"]+)(\")/;
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\\r?\\n/);
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\\[package\\]\\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\\[/.test(line)) inPackage = false;
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\\s*=\\s*\".*\"\\s*$/.test(line)) {
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = \"${version}\"`;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\\n')}\\n`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Tauri release
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

@@ -148,6 +148,27 @@ Use the Playwright MCP to test the app in Metro web. Navigate to `http://localho
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, manual tags, or ad hoc publish commands unless debugging.
```bash
# Recommended: full patch release (bump, check, publish, push branch+tag)
npm run release:patch
# Manual, step-by-step fallback:
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop release)
```
Notes:
- `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
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.

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`).

590
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.2",
"version": "0.1.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -56,6 +56,22 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "2.0.52",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.12"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "2.0.0",
"license": "Apache-2.0",
@@ -1385,6 +1401,27 @@
"react-native": "*"
}
},
"node_modules/@clack/core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.0.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
"version": "0.4.1",
"license": "MIT OR Apache-2.0",
@@ -3679,6 +3716,20 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
@@ -4157,6 +4208,446 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
@@ -6648,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",
@@ -6655,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,
@@ -11796,6 +12302,17 @@
"version": "16.13.1",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
"license": "ISC",
@@ -12028,6 +12545,17 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
@@ -12784,6 +13312,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"license": "MIT",
@@ -12851,6 +13390,14 @@
"version": "0.4.1",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause",
"optional": true,
"peer": true
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"dev": true,
@@ -19788,7 +20335,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -19796,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.2",
"@getpaseo/server": "0.1.4",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -19815,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",
@@ -19897,10 +20446,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2",
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -19950,14 +20500,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.2",
"version": "0.1.4",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -19973,12 +20523,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.2",
"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.2",
"@getpaseo/relay": "0.1.4",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20026,22 +20576,6 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@ai-sdk/openai": {
"version": "2.0.52",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.12"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"license": "MIT",
@@ -20345,7 +20879,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.2",
"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.2",
"version": "0.1.4",
"private": true,
"workspaces": [
"packages/server",
@@ -32,13 +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",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"release:check": "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:prepare": "npm install --workspaces --include-workspace-root",
"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.2",
"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.2",
"@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

@@ -343,7 +343,8 @@ export function ComboSelect({
const anchorRef = useRef<View>(null);
const selectedOption = options.find((opt) => opt.id === value);
const displayValue = selectedOption?.label ?? (value || "");
const displayValue = selectedOption?.label ?? "";
const isEmpty = options.length === 0;
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
@@ -355,7 +356,7 @@ export function ComboSelect({
value={displayValue}
placeholder={placeholder}
onPress={handleOpen}
disabled={disabled}
disabled={disabled || isEmpty}
isLoading={isLoading}
controlRef={anchorRef}
icon={icon}
@@ -567,8 +568,8 @@ export function AgentConfigRow({
title="Select provider"
value={selectedProvider}
options={providerOptions}
placeholder="Select..."
disabled={disabled}
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider}
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}

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

@@ -17,6 +17,14 @@ interface AgentStatusBarProps {
serverId: string;
}
function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized;
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
@@ -61,14 +69,16 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
});
}
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent.model);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = useMemo(() => {
if (!models || !agent.model) return null;
return models.find((m) => m.id === agent.model) ?? null;
}, [models, agent.model]);
if (!models || !preferredModelId) return null;
return models.find((m) => m.id === preferredModelId) ?? null;
}, [models, preferredModelId]);
const displayModel = selectedModel
? selectedModel.label
: agent.model ?? "default";
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const explicitThinkingId =
@@ -156,7 +166,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-model-menu"
>
{models?.map((model) => {
const isActive = model.id === agent.model;
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}
@@ -297,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{models?.map((model) => {
const isActive = model.id === agent.model;
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}

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"
>
@@ -262,27 +393,38 @@ function SidebarContent({
const { theme } = useUnistyles();
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
const hasResolvedCheckoutStatus = status !== null;
// Switch to Files tab if Changes tab is hidden and user was on it
useEffect(() => {
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
onTabPress("files");
}
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
<Pressable
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
!isGit && styles.tabTextMuted,
]}
{isGit && (
<Pressable
testID="explorer-tab-changes"
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
Changes
</Text>
</Pressable>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
)}
<Pressable
testID="explorer-tab-files"
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
@@ -295,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 && (
@@ -313,6 +469,9 @@ function SidebarContent({
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{activeTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
)}
</View>
</View>
);

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
FlatList,
@@ -13,9 +14,13 @@ import {
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -33,6 +38,7 @@ import {
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import type {
@@ -263,6 +269,78 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
setSortOption(SORT_OPTIONS[nextIndex].value);
}, [sortOption, setSortOption]);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, agentId],
queryFn: async () => {
if (!agentId) {
return null;
}
const directoryPaths = Array.from(expandedPaths);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
await Promise.all([
...directoryPaths.map((path) =>
requestDirectoryListing(agentId, path, {
recordHistory: false,
setCurrentPath: false,
})
),
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
]);
return null;
},
enabled: false,
});
const handleRefresh = useCallback(() => {
void refetchExplorer();
}, [refetchExplorer]);
const refreshIconRotation = useSharedValue(0);
useEffect(() => {
if (isRefreshFetching) {
refreshIconRotation.value = 0;
refreshIconRotation.value = withRepeat(
withTiming(360, {
duration: 700,
easing: Easing.linear,
}),
-1,
false
);
return;
}
cancelAnimation(refreshIconRotation);
const remainder = refreshIconRotation.value % 360;
if (Math.abs(remainder) < 0.001) {
refreshIconRotation.value = 0;
return;
}
const remaining = 360 - remainder;
const duration = Math.max(80, Math.round((remaining / 360) * 700));
refreshIconRotation.value = withTiming(
360,
{
duration,
easing: Easing.linear,
},
(finished) => {
if (finished) {
refreshIconRotation.value = 0;
}
}
);
}, [isRefreshFetching, refreshIconRotation]);
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
}));
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
const treeRows = useMemo(() => {
@@ -603,9 +681,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</GestureDetector>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
style={styles.treeList}
@@ -622,9 +718,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
style={styles.treeList}
@@ -1023,6 +1137,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
@@ -1166,6 +1286,16 @@ const styles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonPressed: {
opacity: 0.8,
transform: [{ scale: 0.96 }],
},
refreshIcon: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
previewContent: {
flex: 1,
},

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
@@ -506,6 +509,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
const {
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
refresh: refreshPrStatus,
} = useCheckoutPrStatusQuery({
@@ -517,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);
@@ -621,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;
@@ -644,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,
@@ -799,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";
@@ -840,7 +868,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = prPayloadError?.message ?? null;
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
@@ -930,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.
@@ -993,7 +1024,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
// View PR - when PR exists
if (hasPullRequest && prStatus?.url) {
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
@@ -1008,7 +1039,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
// Create PR - when ahead of base and no PR
if (aheadCount > 0 && !hasPullRequest) {
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
@@ -1112,7 +1143,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,

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]
);
@@ -687,7 +693,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
agentItemUnselected: {
opacity: 0.75,
opacity: 1,
},
agentItemSelected: {
backgroundColor: theme.colors.surface2,

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

@@ -177,7 +177,7 @@ export function ToolCallDetailsContent({
if (plainInputText !== null) {
sections.push(
<View key="unknown-plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
<Text selectable style={styles.plainText}>{plainInputText}</Text>
</View>
);
} else {
@@ -298,6 +298,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
overflowWrap: "anywhere",
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

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
);
@@ -322,7 +327,6 @@ export function SessionProvider({
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
@@ -372,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
@@ -581,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;
}
@@ -645,6 +658,7 @@ export function SessionProvider({
setAgentLastActivity,
setPendingPermissions,
setQueuedMessages,
setAgentTimelineCursor,
]
);
@@ -666,34 +680,6 @@ export function SessionProvider({
[serverId, setFileExplorer]
);
const gitDiffMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
if (!agentId) {
throw new Error("Agent id is required");
}
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitDiff(agentId);
if (payload.error) {
throw new Error(payload.error);
}
return { agentId: payload.agentId, diff: payload.diff ?? "" };
},
});
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) {
@@ -757,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 {
@@ -771,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) {
@@ -820,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") {
@@ -863,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(
@@ -1250,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);
@@ -1275,15 +1348,6 @@ export function SessionProvider({
return next;
});
setGitDiffs(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setFileExplorer(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
@@ -1318,7 +1382,7 @@ export function SessionProvider({
return () => {
unsubAgentUpdate();
unsubAgentStream();
unsubAgentStreamSnapshot();
unsubAgentTimeline();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -1339,74 +1403,20 @@ export function SessionProvider({
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentTimelineCursor,
setInitializingAgents,
setAgents,
setAgentLastActivity,
setPendingPermissions,
setGitDiffs,
setFileExplorer,
setHasHydratedAgents,
updateConnectionStatus,
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,
@@ -1635,24 +1645,6 @@ export function SessionProvider({
[]
);
const requestGitDiff = useCallback(
(agentId: string) => {
gitDiffMutation
.mutateAsync({ agentId })
.then((result) => {
setGitDiffs(serverId, (prev) =>
new Map(prev).set(result.agentId, result.diff)
);
})
.catch((error) => {
setGitDiffs(serverId, (prev) =>
new Map(prev).set(agentId, `Error: ${error.message}`)
);
});
},
[serverId, gitDiffMutation, setGitDiffs]
);
const requestDirectoryListing = useCallback(
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : ".";

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";
@@ -64,38 +65,63 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
const voiceTransportReadyRef = useRef(false);
const voiceResyncInFlightRef = useRef(false);
const silenceGraceStartMsRef = useRef<number | null>(null);
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const speechInterruptStartMsRef = useRef<number | null>(null);
const speechStartInterruptSentRef = useRef(false);
const isVoiceModeRef = useRef(false);
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
isDetecting: false,
isSpeaking: false,
});
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
const timer = speechInterruptTimerRef.current;
if (timer) {
clearTimeout(timer);
speechInterruptTimerRef.current = null;
const startedAt = speechInterruptStartMsRef.current;
speechInterruptStartMsRef.current = null;
if (startedAt !== null) {
console.log("[Voice] Cleared speech-start interrupt timer", {
reason,
elapsedMs: Date.now() - startedAt,
});
} else {
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
}
return;
}
speechInterruptStartMsRef.current = null;
}, []);
const interruptActiveVoiceTurn = useCallback((source: string) => {
const session = realtimeSessionRef.current;
const sessionAudioPlayer = session?.audioPlayer ?? null;
const sessionClient = session?.client ?? null;
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
if (sessionIsPlayingAudio && sessionAudioPlayer) {
if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now();
}
sessionAudioPlayer.stop();
}
try {
if (sessionClient) {
void sessionClient.abortRequest().catch((error) => {
console.error("[Voice] Failed to send abort_request:", error);
});
}
console.log("[Voice] Sent abort_request before streaming audio", { source });
} catch (error) {
console.error("[Voice] Failed to send abort_request:", error);
}
}, []);
const realtimeAudio = useSpeechmaticsAudio({
onSpeechStart: () => {
console.log("[Voice] Segment started (speech confirmed)");
// Stop audio playback if playing
const session = realtimeSessionRef.current;
const sessionAudioPlayer = session?.audioPlayer ?? null;
const sessionClient = session?.client ?? null;
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
if (sessionIsPlayingAudio && sessionAudioPlayer) {
if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now();
}
sessionAudioPlayer.stop();
}
// Abort any in-flight orchestrator turn before the new speech segment streams
try {
if (sessionClient) {
void sessionClient.abortRequest().catch((error) => {
console.error("[Voice] Failed to send abort_request:", error);
});
}
console.log("[Voice] Sent abort_request before streaming audio");
} catch (error) {
console.error("[Voice] Failed to send abort_request:", error);
}
},
onSpeechEnd: () => {
const silenceMs =
@@ -108,6 +134,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
console.log("[Voice] Segment finalized", { silenceMs });
}
silenceGraceStartMsRef.current = null;
clearSpeechStartInterruptTimer("speech ended");
speechStartInterruptSentRef.current = false;
},
onAudioSegment: ({ audioData, isLast }) => {
if (!voiceTransportReadyRef.current) {
@@ -158,6 +186,58 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
realtimeSessionRef.current = activeSession;
}, [activeSession]);
useEffect(() => {
isVoiceModeRef.current = isVoiceMode;
}, [isVoiceMode]);
useEffect(() => {
if (!isVoiceMode) {
clearSpeechStartInterruptTimer("voice mode disabled");
speechStartInterruptSentRef.current = false;
return;
}
if (realtimeAudio.isSpeaking) {
if (
speechStartInterruptSentRef.current ||
speechInterruptTimerRef.current !== null
) {
return;
}
speechInterruptStartMsRef.current = Date.now();
speechInterruptTimerRef.current = setTimeout(() => {
speechInterruptTimerRef.current = null;
speechInterruptStartMsRef.current = null;
if (
!isVoiceModeRef.current ||
!vadStateRef.current.isSpeaking ||
speechStartInterruptSentRef.current
) {
return;
}
speechStartInterruptSentRef.current = true;
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
});
interruptActiveVoiceTurn("speech_start_grace_elapsed");
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
return;
}
clearSpeechStartInterruptTimer("speech stopped before grace");
}, [
clearSpeechStartInterruptTimer,
interruptActiveVoiceTurn,
isVoiceMode,
realtimeAudio.isSpeaking,
]);
useEffect(() => {
return () => {
clearSpeechStartInterruptTimer("voice provider unmounted");
};
}, [clearSpeechStartInterruptTimer]);
useEffect(() => {
const next = {
isDetecting: realtimeAudio.isDetecting,
@@ -187,6 +267,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
// Fully idle (neither detecting nor speaking).
if (!next.isDetecting && !next.isSpeaking) {
silenceGraceStartMsRef.current = null;
speechStartInterruptSentRef.current = false;
}
vadStateRef.current = next;
@@ -254,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,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
describe("useAgentFormState", () => {
describe("__private__.combineInitialValues", () => {
@@ -146,5 +153,127 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to auto", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
);
expect(resolved.model).toBe("");
});
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
const resolved = __private__.resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: {
codex: {
model: "default",
},
},
},
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
);
expect(resolved.model).toBe("");
});
it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS
.filter((definition) => definition.id === "claude")
.map((definition) => [definition.id as AgentProvider, definition])
);
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
allowedProviderMap
);
expect(resolved.provider).toBe("claude");
});
it("does not force fallback provider when allowed provider map is empty", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
new Map<AgentProvider, AgentProviderDefinition>()
);
expect(resolved.provider).toBe("codex");
});
});
});

View File

@@ -89,15 +89,25 @@ type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>;
};
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition])
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
allProviderDefinitions.map((definition) => [definition.id, definition])
);
const fallbackDefinition = providerDefinitions[0];
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
fallbackDefinition?.defaultModeId ?? "";
function normalizeSelectedModelId(
modelId: string | null | undefined
): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return "";
}
return normalized;
}
function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null
): AgentModelDefinition | null {
@@ -136,25 +146,33 @@ function resolveFormState(
availableModels: AgentModelDefinition[] | null,
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as
| AgentProvider
| undefined;
// 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) {
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
result.provider = initialValues.provider;
} else if (
preferences?.provider &&
providerDefinitionMap.has(preferences.provider as AgentProvider)
allowedProviderMap.has(preferences.provider as AgentProvider)
) {
result.provider = preferences.provider as AgentProvider;
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
}
// else keep current (initialized to DEFAULT_PROVIDER)
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
}
const providerDef = providerDefinitionMap.get(result.provider);
const providerDef = allowedProviderMap.get(result.provider);
const providerPrefs = preferences?.providerPreferences?.[result.provider];
// 2. Resolve modeId (depends on provider)
@@ -181,25 +199,24 @@ function resolveFormState(
if (!userModified.model) {
const isValidModel = (m: string) =>
availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
if (
typeof initialValues?.model === "string" &&
initialValues.model.length > 0
) {
if (initialModel) {
// If models aren't loaded yet, trust the initial value
// It will be validated once models load
if (!availableModels || isValidModel(initialValues.model)) {
result.model = initialValues.model;
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
result.model = providerPrefs.model;
if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel;
} else if (preferredModel && isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
}
} else if (typeof providerPrefs?.model === "string" && providerPrefs.model.length > 0) {
} else if (preferredModel) {
// If models haven't loaded yet, optimistically apply the stored preference.
// We'll validate once models load and clear it if it isn't available.
if (!availableModels || isValidModel(providerPrefs.model)) {
result.model = providerPrefs.model;
if (!availableModels || isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
}
@@ -353,6 +370,45 @@ export function useAgentFormState(
const client = sessionState?.client ?? null;
const isConnected = sessionState?.connection?.isConnected ?? false;
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listAvailableProviders();
if (payload.error) {
throw new Error(payload.error);
}
return payload.providers
.filter((entry) => entry.available)
.map((entry) => entry.provider);
},
});
const providerDefinitions = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) =>
available.has(definition.id as AgentProvider)
);
}, [availableProvidersQuery.data]);
const providerDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition])
),
[providerDefinitions]
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
@@ -363,7 +419,14 @@ export function useAgentFormState(
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected),
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider)
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
@@ -403,7 +466,8 @@ export function useAgentFormState(
availableModels,
userModified,
formStateRef.current,
validServerIds
validServerIds,
providerDefinitionMap
);
// Only update if something changed
@@ -428,6 +492,7 @@ export function useAgentFormState(
availableModels,
userModified,
validServerIds,
providerDefinitionMap,
]);
// Auto-select the first online host when:
@@ -500,11 +565,11 @@ export function useAgentFormState(
...prev,
provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: providerPrefs?.model ?? "",
model: normalizeSelectedModelId(providerPrefs?.model),
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
},
[preferences?.providerPreferences, updatePreferences]
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
);
const setModeFromUser = useCallback(
@@ -518,9 +583,10 @@ export function useAgentFormState(
const setModelFromUser = useCallback(
(modelId: string) => {
setFormState((prev) => ({ ...prev, model: modelId }));
const normalizedModelId = normalizeSelectedModelId(modelId);
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: modelId });
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
},
[formState.provider, updateProviderPreferences]
);
@@ -639,6 +705,8 @@ export function useAgentFormState(
setThinkingOptionFromUser,
setWorkingDir,
setWorkingDirFromUser,
providerDefinitions,
providerDefinitionMap,
agentDefinition,
modeOptions,
availableModels,

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

@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
return {
status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
payloadError: query.data?.error ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,

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

@@ -41,7 +41,7 @@ export function useFileExplorerActions(serverId: string) {
);
const requestDirectoryListing = useCallback(
(
async (
agentId: string,
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean }
@@ -77,42 +77,40 @@ export function useFileExplorerActions(serverId: string) {
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "list")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
})
.catch((error) => {
updateExplorerState(agentId, (state) => ({
try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "list");
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
lastError: payload.error ?? null,
pendingRequest: null,
}));
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
} catch (error) {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
pendingRequest: null,
}));
}
},
[client, updateExplorerState]
);
const requestFilePreview = useCallback(
(agentId: string, path: string) => {
async (agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({
...state,
@@ -131,36 +129,34 @@ export function useFileExplorerActions(serverId: string) {
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "file")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
return nextState;
});
})
.catch(() => {
updateExplorerState(agentId, (state) => ({
try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "file");
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
pendingRequest: null,
}));
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
return nextState;
});
} catch {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
pendingRequest: null,
}));
}
},
[client, updateExplorerState]
);

View File

@@ -1,70 +0,0 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import { usePanelStore } from "@/stores/panel-store";
const GIT_DIFF_STALE_TIME = 30_000;
function gitDiffQueryKey(serverId: string, agentId: string) {
return ["gitDiff", serverId, agentId] as const;
}
interface UseGitDiffQueryOptions {
serverId: string;
agentId: string;
}
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
const queryClient = useQueryClient();
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const query = useQuery({
queryKey: gitDiffQueryKey(serverId, agentId),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const response = await client.getGitDiff(agentId);
return response.diff;
},
enabled: !!client && isConnected && !!agentId,
staleTime: GIT_DIFF_STALE_TIME,
refetchInterval: 10_000,
});
// Revalidate when sidebar opens with "changes" tab active
useEffect(() => {
if (!isOpen || explorerTab !== "changes" || !agentId) {
return;
}
// Invalidate to trigger background refetch (shows stale data while fetching)
queryClient.invalidateQueries({
queryKey: gitDiffQueryKey(serverId, agentId),
});
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
return {
diff: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refresh,
};
}

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
@@ -785,6 +820,13 @@ export function DraftAgentScreen({
dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" });
throw new Error("No host selected");
}
if (providerDefinitions.length === 0) {
dispatch({
type: "DRAFT_SET_ERROR",
message: "No available providers on the selected host",
});
throw new Error("No available providers on the selected host");
}
if (gitBlockingError) {
dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError });
throw new Error(gitBlockingError);
@@ -865,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;
}
@@ -894,6 +938,7 @@ export function DraftAgentScreen({
isDirectoryNotExists,
isNonGitDirectory,
modeOptions,
providerDefinitions,
persistFormPreferences,
router,
selectedMode,
@@ -965,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"
@@ -973,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 && (
@@ -1051,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={
@@ -1122,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>;
@@ -201,9 +210,6 @@ export interface SessionState {
// Permissions
pendingPermissions: Map<string, PendingPermission>;
// Git diffs
gitDiffs: Map<string, string>;
// File explorer
fileExplorer: Map<string, AgentFileExplorerState>;
@@ -243,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;
@@ -258,9 +270,6 @@ interface SessionStoreActions {
// Permissions
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
// Git diffs
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
// File explorer
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
@@ -327,17 +336,24 @@ 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(),
agents: new Map(),
pendingPermissions: new Map(),
gitDiffs: new Map(),
fileExplorer: new Map(),
queuedMessages: new Map(),
};
}
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: {},
@@ -439,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;
}
@@ -455,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 } : {}),
},
},
},
};
@@ -610,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];
@@ -745,28 +795,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
// Git diffs
setGitDiffs: (serverId, diffs) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs;
if (session.gitDiffs === nextDiffs) {
return prev;
}
logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size });
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, gitDiffs: nextDiffs },
},
};
});
},
// File explorer
setFileExplorer: (serverId, state) => {
set((prev) => {

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

@@ -19,4 +19,13 @@ describe("extractAgentModel", () => {
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
});
it("treats legacy 'default' model ids as unset", () => {
const agent = {
model: "default",
runtimeInfo: { model: "default" },
} as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBeNull();
});
});

View File

@@ -4,11 +4,17 @@ export function extractAgentModel(agent?: Agent | null): string | null {
if (!agent) return null;
const runtimeModel = agent.runtimeInfo?.model;
const fallbackModel = agent.model;
if (typeof runtimeModel === "string" && runtimeModel.trim().length > 0) {
return runtimeModel.trim();
if (typeof runtimeModel === "string") {
const normalized = runtimeModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
return normalized;
}
}
if (typeof fallbackModel === "string" && fallbackModel.trim().length > 0) {
return fallbackModel.trim();
if (typeof fallbackModel === "string") {
const normalized = fallbackModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
return normalized;
}
}
return null;
}

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,5 +1,14 @@
import type { ComponentType } from "react";
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
import {
Bot,
Brain,
Eye,
MicVocal,
Pencil,
Search,
SquareTerminal,
Wrench,
} from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
@@ -20,6 +29,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
return Brain;
}
if (lowerName === "speak") {
return MicVocal;
}
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];

View File

@@ -9,4 +9,6 @@ export const REALTIME_VOICE_VAD_CONFIG = {
silenceDurationMs: 2000,
speechConfirmationMs: 120,
detectionGracePeriodMs: 700,
// Delay speech-start interrupts to ignore transient noise triggers.
interruptGracePeriodMs: 1000,
} as const;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.2",
"version": "0.1.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -21,8 +21,9 @@
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
"@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2",
"@clack/prompts": "^1.0.0",
"@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

@@ -16,7 +16,9 @@ import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { runUpdateCommand } from './commands/agent/update.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
const VERSION = '0.1.0'
@@ -42,11 +44,10 @@ export function createCli(): Command {
// Primary agent commands (top-level)
program
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLsCommand))
@@ -66,6 +67,7 @@ export function createCli(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
@@ -126,7 +128,23 @@ export function createCli(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
program
.command('update')
.description('Update an agent (alias for "paseo agent update")')
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
// Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand())
program

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

@@ -9,6 +9,7 @@ import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
export function createAgentCommand(): Command {
@@ -22,11 +23,10 @@ export function createAgentCommand(): Command {
// Primary agent commands (same as top-level)
agent
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLsCommand))
@@ -43,6 +43,7 @@ export function createAgentCommand(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
@@ -121,5 +122,20 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
.command('update')
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
}

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

@@ -74,26 +74,22 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
export type AgentLsResult = ListResult<AgentListItem>
export interface AgentLsOptions extends CommandOptions {
/** -a: Include all statuses (not just running/idle) */
/** -a: Include archived agents */
all?: boolean
/** -g: Show agents globally (not just current directory) */
/** Legacy flag retained for CLI compatibility */
global?: boolean
/** Filter by specific status */
status?: string
/** Filter by specific cwd (overrides default cwd filtering) */
/** Filter by specific cwd */
cwd?: string
/** Filter by labels (key=value format) */
label?: string[]
/** Filter to UI agents only (equivalent to --label ui=true) */
ui?: boolean
}
/**
* Agent ls command with correct semantics from design doc:
* - `paseo agent ls` running/idle agents in current directory
* - `paseo agent ls -a` all statuses in current directory
* - `paseo agent ls -g` → running/idle agents globally
* - `paseo agent ls -ag` → everything everywhere
* Agent ls command semantics:
* - `paseo agent ls` → all non-archived agents
* - `paseo agent ls -a` → include archived agents
*/
export async function runLsCommand(
options: AgentLsOptions,
@@ -117,39 +113,26 @@ export async function runLsCommand(
try {
let agents = await client.fetchAgents()
// Status filtering:
// By default, only show running/idle agents (not error, archived, etc.)
// With -a flag, show all statuses
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {
agents = agents.filter((a) => {
// Show running and idle agents, exclude archived
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
})
agents = agents.filter((a) => !a.archivedAt)
}
// If explicit status filter is provided, use it
// If explicit status filter is provided, apply it.
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
}
// Directory filtering:
// By default, only show agents in current working directory
// With -g flag, show agents globally (all directories)
if (!options.global) {
const currentCwd = options.cwd ?? process.cwd()
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, '')
agents = agents.filter((a) => {
// Normalize paths for comparison
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = currentCwd.replace(/\/$/, '')
// Match exact cwd or subdirectories
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
}
// Label filtering:
// Parse --label flags and --ui flag
// --ui is equivalent to --label ui=true
// By default (no --ui flag), show background agents (those WITHOUT ui=true)
// Parse --label filters (key=value).
const labelFilters: Record<string, string> = {}
if (options.label) {
for (const labelStr of options.label) {
@@ -161,14 +144,9 @@ export async function runLsCommand(
}
}
}
// Add ui=true filter if --ui flag is set
if (options.ui) {
labelFilters['ui'] = 'true'
}
// Apply label filtering
// Apply label filtering only when explicitly requested.
if (Object.keys(labelFilters).length > 0) {
// Filter to agents that have ALL specified labels (AND semantics)
agents = agents.filter((a) => {
const agentLabels = a.labels
for (const [key, value] of Object.entries(labelFilters)) {
@@ -178,11 +156,6 @@ export async function runLsCommand(
}
return true
})
} else {
// Default: show background agents only (those without ui=true)
agents = agents.filter((a) => {
return a.labels['ui'] !== 'true'
})
}
await client.close()

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

@@ -9,7 +9,7 @@ import { lookup } from 'mime-types'
/** Result type for agent run command */
export interface AgentRunResult {
agentId: string
status: 'created' | 'running'
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
provider: string
cwd: string
title: string | null
@@ -39,24 +39,178 @@ export interface AgentRunOptions extends CommandOptions {
cwd?: string
label?: string[]
ui?: boolean
outputSchema?: string
}
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult['status']
): AgentRunResult {
return {
agentId: agent.id,
status: agent.status === 'running' ? 'running' : 'created',
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
provider: agent.provider,
cwd: agent.cwd,
title: agent.title,
}
}
function loadOutputSchema(value: string): Record<string, unknown> {
const trimmed = value.trim()
if (!trimmed) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: '--output-schema cannot be empty',
details: 'Provide a JSON schema file path or inline JSON object',
}
throw error
}
let source = trimmed
if (!trimmed.startsWith('{')) {
try {
source = readFileSync(resolve(trimmed), 'utf8')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: `Failed to read output schema file: ${trimmed}`,
details: message,
}
throw error
}
}
let parsed: unknown
try {
parsed = JSON.parse(source)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Failed to parse output schema JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Output schema must be a JSON object',
}
throw error
}
return parsed as Record<string, unknown>
}
function extractFirstJsonObject(text: string): string | null {
const source = text.trim()
if (!source) {
return null
}
const startIndexes: number[] = []
for (let i = 0; i < source.length; i += 1) {
if (source[i] === '{') {
startIndexes.push(i)
}
}
for (const start of startIndexes) {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < source.length; i += 1) {
const ch = source[i]!
if (inString) {
if (escaped) {
escaped = false
continue
}
if (ch === '\\') {
escaped = true
continue
}
if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '{') {
depth += 1
continue
}
if (ch === '}') {
depth -= 1
if (depth === 0) {
const candidate = source.slice(start, i + 1).trim()
try {
JSON.parse(candidate)
return candidate
} catch {
// Keep scanning.
}
}
}
}
}
return null
}
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
const trimmed = lastMessage.trim()
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response is not valid JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response JSON must be an object',
}
throw error
}
return parsed as Record<string, unknown>
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
serialize: () => output,
}
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
// Validate prompt is provided
if (!prompt || prompt.trim().length === 0) {
@@ -78,6 +232,16 @@ export async function runRunCommand(
throw error
}
// --output-schema always runs in attached/wait mode
if (outputSchema && options.detach) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--output-schema cannot be used with --detach',
details: 'Structured output requires waiting for the agent to finish',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
@@ -162,12 +326,75 @@ export async function runRunCommand(
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
outputSchema,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
if (outputSchema) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
if (state.status === 'timeout') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Timed out waiting for structured output',
}
throw error
}
if (state.status === 'permission') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent is waiting for permission before producing structured output',
}
throw error
}
if (state.status === 'error') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: state.error ?? 'Agent failed before producing structured output',
}
throw error
}
const lastMessage = state.lastMessage?.trim()
if (!lastMessage) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
}
const output = parseStructuredOutput(lastMessage)
await client.close()
return {
type: 'single',
data: toRunResult(agent, 'completed'),
schema: structuredRunSchema(output),
}
}
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
await client.close()
const finalAgent = state.final ?? agent
const status: AgentRunResult['status'] =
state.status === 'idle' ? 'completed' : state.status
return {
type: 'single',
data: toRunResult(finalAgent, status),
schema: agentRunSchema,
}
}
await client.close()
return {
@@ -177,6 +404,11 @@ export async function runRunCommand(
}
} catch (err) {
await client.close().catch(() => {})
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',

View File

@@ -0,0 +1,177 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent update command */
export interface AgentUpdateResult {
agentId: string
name: string | null
labels: string
}
/** Schema for update command output */
export const updateSchema: OutputSchema<AgentUpdateResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'NAME', field: 'name' },
{ header: 'LABELS', field: 'labels' },
],
}
export interface AgentUpdateOptions extends CommandOptions {
name?: string
label?: string[]
host?: string
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {}
if (!labels) {
return parsed
}
for (const rawLabel of labels) {
for (const segment of rawLabel.split(',')) {
const label = segment.trim()
if (!label) {
continue
}
const eqIndex = label.indexOf('=')
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must be in key=value format',
}
throw error
}
const key = label.slice(0, eqIndex).trim()
const value = label.slice(eqIndex + 1)
if (!key) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must include a non-empty key in key=value format',
}
throw error
}
parsed[key] = value
}
}
return parsed
}
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels)
if (entries.length === 0) {
return '-'
}
return entries.map(([key, value]) => `${key}=${value}`).join(',')
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
_command: Command
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
}
throw error
}
const name = options.name?.trim()
if (options.name !== undefined && !name) {
const error: CommandError = {
code: 'INVALID_NAME',
message: 'Name cannot be empty',
details: 'Use --name <name> with a non-empty value',
}
throw error
}
const labels = parseLabelOptions(options.label)
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: 'NO_CHANGES_PROVIDED',
message: 'Nothing to update',
details: 'Provide at least one of: --name <name>, --label <key=value>',
}
throw error
}
let client
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(agentIdArg)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
const agentId = agent.id
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
})
const updated = await client.fetchAgent(agentId)
if (!updated) {
throw new Error(`Agent not found after update: ${agentId}`)
}
await client.close()
return {
type: 'single',
data: {
agentId,
name: updated.title,
labels: formatLabels(updated.labels),
},
schema: updateSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'UPDATE_FAILED',
message: `Failed to update agent: ${message}`,
}
throw error
}
}

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

@@ -0,0 +1,468 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
import { Command } from 'commander'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import {
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type CliConfigOverrides,
type PersistedConfig,
} from '@getpaseo/server'
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
resolveTcpHostFromListen,
startLocalDaemonDetached,
tailDaemonLog,
type DaemonStartOptions,
} from './daemon/local-daemon.js'
import { tryConnectToDaemon } from '../utils/client.js'
interface OnboardOptions extends DaemonStartOptions {
timeout?: string
voice?: 'ask' | 'enable' | 'disable'
}
type OnboardPersistedConfig = PersistedConfig & {
providers?: PersistedConfig['providers'] & {
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
}
features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
}
}
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
class OnboardCancelledError extends Error {}
const plainNoteFormat = (line: string): string => line
function renderNote(message: string, title: string): void {
note(message, title, { format: plainNoteFormat })
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function parseTimeoutMs(raw: string | undefined): number {
if (!raw || raw.trim().length === 0) {
return DEFAULT_READY_TIMEOUT_MS
}
const seconds = Number(raw)
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new Error(`Invalid timeout value: ${raw}`)
}
return Math.ceil(seconds * 1000)
}
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {}
if (options.listen) {
cliOverrides.listen = options.listen
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`
}
if (options.relay === false) {
cliOverrides.relayEnabled = false
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim()
cliOverrides.allowedHosts =
raw.toLowerCase() === 'true'
? true
: raw.split(',').map(host => host.trim()).filter(Boolean)
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false
}
return cliOverrides
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, 'config.json')
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
return {
...config,
providers: {
...config.providers,
local: {
...config.providers?.local,
autoDownload: enabled,
},
},
features: {
...config.features,
dictation: {
...config.features?.dictation,
enabled,
},
voiceMode: {
...config.features?.voiceMode,
enabled,
},
},
}
}
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
const voiceModeEnabled = config.features?.voiceMode?.enabled
if (typeof voiceModeEnabled === 'boolean') {
return voiceModeEnabled
}
const dictationEnabled = config.features?.dictation?.enabled
if (typeof dictationEnabled === 'boolean') {
return dictationEnabled
}
return null
}
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
if (mode === 'enable') {
return true
}
if (mode === 'disable') {
return false
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
return false
}
const answer = await confirm({
message: 'Enable voice features? (downloads local STT/TTS models in background)',
active: 'Yes',
inactive: 'No',
initialValue: false,
})
if (isCancel(answer)) {
throw new OnboardCancelledError('Onboarding cancelled by user.')
}
return answer
}
type DownloadProgress = {
modelId: string | null
pct: number | null
}
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split('\n').filter(Boolean)
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]
if (!line || !line.includes('Downloading model artifact')) {
continue
}
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
const modelMatch = line.match(
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
)
return {
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
}
}
return null
}
function renderProgressLine(progress: DownloadProgress): string {
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
if (progress.pct === null) {
return `Downloading speech model${modelSuffix}...`
}
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
}
async function waitForDaemonReady(args: {
home: string
timeoutMs: number
onStatus?: (message: string) => void
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs
let lastStatus = ''
let lastPrintedAt = 0
while (Date.now() < deadline) {
const state = resolveLocalDaemonState({ home: args.home })
const host = resolveTcpHostFromListen(state.listen)
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 })
if (client) {
try {
await client.fetchAgents()
return { listen: state.listen, host }
} catch {
// Daemon process is alive but not API-ready yet.
} finally {
await client.close().catch(() => {})
}
}
} else if (state.running && !host) {
return { listen: state.listen, host: null }
}
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
const progressLine = progress ? renderProgressLine(progress) : null
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
if (statusMessage !== lastStatus) {
args.onStatus?.(statusMessage)
lastStatus = statusMessage
lastPrintedAt = Date.now()
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
console.log(statusMessage)
lastPrintedAt = Date.now()
}
await sleep(200)
}
const recentLogs = tailDaemonLog(args.home, 60)
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
}
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
const daemonLogPath = path.join(paseoHome, 'daemon.log')
const nextStepsLines = [
pairingUrl
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
: '1. Open Paseo and connect to your daemon.',
'2. Web app: https://app.paseo.sh',
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
'4. Docs: https://paseo.sh/docs',
'5. Example: paseo run --output-schema schema.json "extract fields"',
]
const quickReferenceLines = [
'1. paseo --help',
'2. paseo ls',
'3. paseo run "your prompt"',
'4. paseo status',
`5. Daemon logs: ${daemonLogPath}`,
]
if (!richUi) {
console.log('')
console.log('Next steps:')
for (const line of nextStepsLines) {
console.log(line)
}
console.log('')
console.log('CLI quick reference:')
for (const line of quickReferenceLines) {
console.log(line)
}
return
}
renderNote(nextStepsLines.join('\n'), 'Next steps')
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
}
export function onboardCommand(): Command {
return new Command('onboard')
.description('Run first-time setup, start daemon, and print pairing instructions')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
)
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
.action(async (options: OnboardOptions) => {
await runOnboard(options)
})
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
const richUi = process.stdin.isTTY && process.stdout.isTTY
if (richUi) {
intro('Welcome to Paseo')
}
if (options.listen && options.port) {
cancel('Cannot use --listen and --port together')
process.exit(1)
}
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
try {
timeoutMs = parseTimeoutMs(options.timeout)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
cancel(message)
process.exit(1)
}
const paseoHome = resolveLocalPaseoHome(options.home)
if (richUi) {
renderNote(paseoHome, 'Paseo home')
}
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
let voiceEnabled: boolean
try {
voiceEnabled =
shouldPrompt && persistedVoiceSelection !== null
? persistedVoiceSelection
: await resolveVoiceSelection(options.voice)
} catch (error) {
if (error instanceof OnboardCancelledError) {
cancel('Onboarding cancelled.')
process.exit(0)
return
}
throw error
}
if (shouldPrompt && persistedVoiceSelection !== null) {
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
}
persisted = applyVoiceSelection(persisted, voiceEnabled)
savePersistedConfig(paseoHome, persisted)
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const voiceStatus = voiceEnabled
? '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)
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
const startSpinner = richUi ? spinner() : null
if (!stateBeforeStart.running) {
try {
if (startSpinner) {
startSpinner.start('Starting daemon...')
} else {
log.message('Starting daemon...')
}
const startup = await startLocalDaemonDetached(options)
if (startSpinner) {
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
} else {
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
}
log.message(`Logs: ${startup.logPath}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (startSpinner) {
startSpinner.error(message)
} else {
log.error(message)
}
process.exit(1)
}
} else {
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
}
let readyState: { listen: string; host: string | null }
const readySpinner = richUi ? spinner() : null
try {
if (readySpinner) {
readySpinner.start('Waiting for daemon to become ready...')
} else {
log.message('Waiting for daemon to become ready...')
}
readyState = await waitForDaemonReady({
home: options.home ?? paseoHome,
timeoutMs,
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
})
if (readySpinner) {
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
} else {
log.message(`Daemon ready on ${readyState.listen}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (readySpinner) {
readySpinner.error(message)
} else {
log.error(message)
}
process.exit(1)
return
}
if (config.relayEnabled === false) {
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
printNextSteps(null, paseoHome, richUi)
if (richUi) {
outro('Paseo daemon is running.')
}
return
}
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
if (!pairing.url) {
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
printNextSteps(null, paseoHome, richUi)
if (richUi) {
outro('Paseo daemon is running.')
}
return
}
renderNote(
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
'Scan to pair'
)
renderNote(pairing.url, 'Pairing link')
printNextSteps(pairing.url, paseoHome, richUi)
if (richUi) {
outro('Paseo is ready!')
}
}

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

@@ -2,6 +2,6 @@ import { createCli } from './cli.js'
const program = createCli()
if (process.argv.length <= 2) {
process.argv.push('start')
process.argv.push('onboard')
}
program.parse()

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