Compare commits

...

102 Commits

Author SHA1 Message Date
Mohamed Boudra
b27c1b0729 chore(release): cut 0.1.51 2026-04-09 13:58:59 +07:00
Mohamed Boudra
fcf2c14485 docs(changelog): add 0.1.51 release notes 2026-04-09 13:57:44 +07:00
Mohamed Boudra
57eafda6ef fix(app): fix BottomSheetTextInput crash on iPad model selector
On iPad, Combobox uses the desktop Modal path (no BottomSheet context)
since the breakpoint is "md"+, but ProviderSearchInput was checking
Platform.OS === "web" to choose the input component. This caused
BottomSheetTextInput to render outside a BottomSheet on iPad.

Align the check with Combobox's own isMobile breakpoint logic.
2026-04-09 11:18:49 +07:00
Mohamed Boudra
4925b94743 Use advertised hostnames during pairing 2026-04-09 10:32:01 +07:00
Mohamed Boudra
f0b09d90fd fix(app): clean up QR scan screen — remove instruction text, use accent color for crosshairs 2026-04-09 10:31:33 +07:00
Mohamed Boudra
bf91ea2cc9 feat(server): support image attachments in OpenCode agent prompts
Adds logic to process file parts and convert image attachments into data URLs for the OpenCode provider, replacing the previous behavior that stripped non-text parts.
2026-04-09 10:23:24 +07:00
Mohamed Boudra
f83a4d08da Add configurable send behavior 2026-04-09 09:56:04 +07:00
Mohamed Boudra
83f540f10a fix(app): wrap daemon settings section in React Query with loading state
Prevents the daemon section from flashing "not running" / "PID —" while
IPC data loads. The whole card now shows a spinner until the first fetch
completes, and mutations (restart/stop/start) optimistically update the
query cache with the returned status.
2026-04-09 09:45:51 +07:00
Illia Panasenko
602b548745 feat(app): Add WebStorm editor target (#220)
* Add WebStorm editor target

* Fix rebase type errors and add TODO date tag

- Await listAvailableEditorTargets() which became async on main
- Wrap sendAgentMessage callback to match Promise<void> return type
- Add TODO(2026-07) date to legacy editor target ids comment

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-09 10:26:53 +08:00
Benjamin Kitt
d98d5457ed Fix commands not loading for Pi agent (#225) 2026-04-09 10:10:11 +08:00
Mohamed Boudra
6bf8da8087 Commit current desktop and CLI diff 2026-04-09 00:27:20 +07:00
Mohamed Boudra
d39414064e Fix opencode terminal states and model refresh 2026-04-09 00:25:30 +07:00
Mohamed Boudra
ffcc35485d Fix agent follow-up turns and wait lifecycle 2026-04-09 00:24:58 +07:00
Mohamed Boudra
8ff94dc03e fix(app): improve pair device screen with read-only input for link
Replace truncating text with a read-only TextInput + copy button pattern
so the pairing URL doesn't get cut off. Increase QR code size from
200x200 to 320x320.
2026-04-08 23:36:07 +07:00
Mohamed Boudra
dace4f862f refactor: make executable resolution async and centralize spawn
- Rename findExecutable → findExecutableSync, add async findExecutable
  that uses promisified execFile instead of execFileSync
- Simplify Windows PATH resolution: rely on inherited env from Explorer
  instead of shelling out to PowerShell for registry PATH entries
- Add spawnProcess() helper that centralizes Windows shell/quoting concerns
- Update all agent providers and consumers to use async executable resolution
- Add windowsHide: true across all Windows child process calls
2026-04-08 23:24:43 +07:00
Mohamed Boudra
fc667d6312 fix(claude): filter <local-command-stdout> messages from timeline
Claude Code writes local CLI command outputs (model switches, /context,
/compact, goodbye messages, etc.) as user entries in the JSONL history.
These were leaking through as user_message timeline items in Paseo.
2026-04-08 22:03:30 +07:00
Mohamed Boudra
aae4d9f8dd docs(website): update mobile app availability status
Apps are now available on App Store and Play Store.
2026-04-08 21:34:37 +07:00
Mohamed Boudra
5eb8b300a3 fix(ci): prevent duplicate GitHub releases in desktop workflow
Add a create-release job that runs first and creates the GitHub release
before any platform build jobs start. This prevents the race condition
where parallel electron-builder jobs each create their own release.

Platform jobs now depend on create-release and just upload assets to
the existing release. Single-platform retry tags (desktop-macos-v*,
desktop-linux-v*, desktop-windows-v*) skip the create-release job
since the release already exists from the original build.
2026-04-08 19:37:30 +07:00
github-actions[bot]
6c9a832906 fix: update lockfile signatures and Nix hash 2026-04-08 12:20:15 +00:00
Mohamed Boudra
35430dab52 chore(release): cut 0.1.51-rc.1 2026-04-08 19:19:07 +07:00
Mohamed Boudra
0eac4bc4b3 fix(desktop): enable electron-log console transport for stdout output
Ensures renderer console.log output (including layout-debug) is
printed to stdout in packaged builds, not just the log file.
Also removes unnecessary webContents event listeners.
2026-04-08 19:11:06 +07:00
Mohamed Boudra
635de3d76a debug(desktop): add renderer console-message and lifecycle event logging
Pipes all renderer console.log output to the main process stdout via
electron-log, and logs did-finish-load/did-fail-load/render-process-gone
events to help diagnose layout and loading issues on Linux.
2026-04-08 19:03:45 +07:00
Mohamed Boudra
931d3ba81f fix: log bootstrap errors to daemon.log and add layout debug logging
- Log fatal errors during daemon bootstrap and startup to pino (daemon.log)
  instead of only writing to stderr, which is invisible on Windows desktop
- Delay process.exit to let pino async streams flush
- Make voice MCP bridge script resolution non-fatal (warn + disable instead
  of crashing the daemon)
- Add PASEO_ELECTRON_FLAGS env var for passing Chromium flags on Linux
- Add layout debug logging to AppContainer for diagnosing sidebar issues
2026-04-08 18:55:56 +07:00
Illia Panasenko
6a4f439541 refactor(app): improve route types, reduce amount of as any (#217) 2026-04-08 12:52:37 +08:00
Mohamed Boudra
ac5e6df6c9 feat(website): add Plausible analytics to all pages 2026-04-08 11:18:03 +07:00
Mohamed Boudra
bf7d3c2775 copy: update hero headline to focus on orchestration value prop 2026-04-08 11:05:42 +07:00
Mohamed Boudra
5c93fbc955 feat: emit usage_updated events for real-time token usage updates 2026-04-08 08:59:02 +07:00
Mohamed Boudra
5ac7b3f7c7 docs(release): add changelog voice guidelines 2026-04-07 20:26:01 +07:00
Mohamed Boudra
c742f17080 docs(changelog): remove internal-only fixes from 0.1.50 notes 2026-04-07 20:24:02 +07:00
Mohamed Boudra
b4d6a5d6b8 docs(changelog): rewrite release notes for end users 2026-04-07 20:22:04 +07:00
github-actions[bot]
43f01600ce fix: update lockfile signatures and Nix hash 2026-04-07 13:05:48 +00:00
Mohamed Boudra
e7cf9ee69d chore(release): cut 0.1.50 2026-04-07 20:04:13 +07:00
Mohamed Boudra
9c8b0c3aca docs(changelog): add 0.1.50 release notes 2026-04-07 20:03:22 +07:00
Mohamed Boudra
8088c39fd6 Fix OpenCode usage regression and update workspace tests
Re-add usage stamping on turn_completed events for OpenCode agents —
the extractAndResetUsage callsite was removed during context window
meter work, causing token counts and cost to be lost after each turn.

Update workspace tests to account for the new async reconciliation
that fires after workspace update fanout.
2026-04-07 20:02:48 +07:00
Mohamed Boudra
1279f1d556 Fix split diff horizontal scroll and pin line number gutters
Split diff now renders two independent scrollable columns (left/right)
instead of one scroll wrapping both sides. Line number gutters are
pinned outside the scroll area in both unified and split views.
2026-04-07 20:00:46 +07:00
Mohamed Boudra
0defbc1dc3 Normalize Codex paseo_voice.speak MCP calls and wrap spoken input
Broaden the Codex speak tool name matcher to recognize paseo_voice.speak
MCP calls alongside the existing paseo.speak convention. Wrap voice
transcription input in <spoken-input> tags so agents can distinguish
spoken from typed messages.
2026-04-07 18:57:03 +07:00
Mohamed Boudra
76c6253ae0 Persist context window usage across turns from last task_progress
Stop resetting lastContextWindowUsedTokens between turns. The
result.usage fallback contains accumulated session totals which grow
incorrectly across turns. Once a task_progress arrives, its value is
the accurate context fill level and should be retained until the next
task_progress supersedes it.
2026-04-07 18:56:58 +07:00
Mohamed Boudra
5ec25687cd Skip provider refresh when one is already in-flight 2026-04-07 18:56:53 +07:00
Mohamed Boudra
33a1557aed Show provider error details in settings screen 2026-04-07 18:56:51 +07:00
Mohamed Boudra
cbc2ce06e9 Fix WorkingIndicator remounting on every stream update on native
Passing an arrow function to FlatList's ListHeaderComponent caused React
to treat each re-render as a new component type, unmounting and
remounting the entire header (including the bouncing dots animation).
Pass the element directly instead so React can reconcile properly.
2026-04-07 18:56:47 +07:00
Mohamed Boudra
82466aaa9f Reset Silero VAD between voice turns to prevent LSTM drift
The Silero VAD session ran for the entire voice mode lifetime without
resetting its LSTM hidden states. Over time the internal state drifted,
causing phantom speech detection on silence and getting permanently
stuck in the "speaking" phase.

Add reset() to TurnDetectionSession and call it after each completed
utterance to clear the LSTM hidden states between turns.
2026-04-07 18:41:46 +07:00
Mohamed Boudra
d3e3a83a0d Render speak tool calls inline as spoken messages
Instead of showing speak tool calls as expandable tool call badges,
render them inline with a mic icon header matching assistant message
styling.
2026-04-07 17:15:18 +07:00
Mohamed Boudra
ee611d65b6 Surface wait_for_finish agent errors 2026-04-07 16:50:17 +07:00
Mohamed Boudra
5ce4562eed Preserve workspace diff stats across rehydration 2026-04-07 16:48:51 +07:00
Mohamed Boudra
21c7761403 Defer workspace diff stats until post-bootstrap updates 2026-04-07 16:09:47 +07:00
Mohamed Boudra
682fc54778 feat: normalize plan approval across providers 2026-04-07 15:43:14 +07:00
Mohamed Boudra
e06b691d5d feat: show remaining context window for Claude Code, Codex, and OpenCode
Track and display context window usage across all three agent providers,
letting users see how much context remains before hitting limits.
2026-04-07 15:03:29 +07:00
Mohamed Boudra
022eb33234 Fix OpenCode context window meter after first turn 2026-04-07 14:53:00 +07:00
Mohamed Boudra
161b2c2378 fix(app): port optimistic workspace tab closing 2026-04-07 14:30:32 +07:00
Mohamed Boudra
52dfdb1913 fix(app): use shared provider snapshot hook in settings and add refresh button
Settings providers section now uses useProvidersSnapshot instead of a
one-shot fetch, so it shares the same real-time data source as the
status bar and draft form. Adds a Refresh button to re-detect installed
provider CLIs without restarting the daemon.
2026-04-07 14:17:52 +07:00
Mohamed Boudra
bdaa6b65aa fix(app): fix garbled overlapping text in plan card markdown
PlanCard list_item rule wrapped children in <Text> instead of <View>,
causing layout corruption when children contained <View> elements
(nested lists, paragraphs). Also added missing paragraph rule to match
AssistantMessage renderer.
2026-04-07 13:22:58 +07:00
Mohamed Boudra
1900f43049 fix(app): move reload agent away from close action 2026-04-07 12:20:15 +07:00
Mohamed Boudra
29b6f2a86f fix(server): prefer origin/{branch} over local branch for worktrees
When creating a worktree, resolve the base branch by checking
origin/{branch} first, falling back to the local branch only when
the remote ref doesn't exist. This ensures worktrees start from the
latest upstream state rather than a potentially stale local branch.
2026-04-07 11:40:59 +07:00
Mohamed Boudra
638c208609 feat(server): add background git fetch manager
Periodically fetch from remotes in the background so workspace git
watch targets pick up upstream changes without user intervention.
The manager deduplicates subscriptions per repo and triggers a
workspace refresh callback after each successful fetch.
2026-04-07 11:40:43 +07:00
Mohamed Boudra
7b1144dafe feat(app): persist expanded state in file explorer and diff panes
Remember which directories and diff entries are expanded across sidebar
close/reopen cycles. State is stored per-workspace in the panel store
(zustand + AsyncStorage) with no local React state copy, following the
proven explorerTabByCheckout pattern. On desktop remount, expanded
subdirectory listings are re-fetched so the tree renders fully.
2026-04-07 11:40:37 +07:00
Mohamed Boudra
940bc6243b fix(app): polish diff toolbar toggle buttons
- Lower active button surface from surface3 to surface2 for dark mode
- Add gap between toolbar button groups
- Remove outer border from unified/split group, use per-button radius
2026-04-07 09:50:52 +07:00
Mohamed Boudra
51b83768c7 fix(server): push workspace updates instantly, reconcile in background
emitWorkspaceUpdateForCwd and emitWorkspaceUpdatesForCwds blocked on
reconcileActiveWorkspaceRecords() (stat + reconcile on every active
workspace) before pushing the target workspace update to clients. This
caused new worktrees to appear in the sidebar with a noticeable delay
after the agent was already visible.

Emit the target workspace immediately from the registry snapshot, then
run reconciliation in the background via reconcileAndEmitWorkspaceUpdates.
Ensure the dedupeGitState early-return still triggers background
reconciliation so metadata changes (branch renames, display names)
are not silently dropped.
2026-04-07 09:50:39 +07:00
Samuel K
cdbaa8d29c fix(server): serve workspace list instantly on fetch, reconcile in background (#204)
* fix(server): serve workspace list instantly on fetch, reconcile in background

Previously fetch_workspaces_request awaited reconcileActiveWorkspaceRecords()
before responding — this ran git operations (getCheckoutStatusLite) on every
active workspace, causing projects to appear empty for several seconds after
daemon restart or reconnect.

Now the registry snapshot is returned immediately and reconciliation runs in
the background. Any changed workspaces (e.g. stale worktrees being archived)
are pushed as workspace_update events through the existing subscription.

* fix(server): make workspace snapshot instant by removing per-workspace git calls

describeWorkspaceRecord was running two git operations per workspace:
  - buildProjectPlacement (to refresh display name)
  - getCheckoutShortstat (for diff indicator)

With 13 workspaces this caused fetch_workspaces_request to take 7-15s
(observed as ws_slow_request in logs).

Split into two methods:
  - describeWorkspaceRecord: uses only persisted data, returns instantly
  - describeWorkspaceRecordWithGitData: runs git ops, used for open_project
    and create_worktree responses where fresh data is needed immediately

The snapshot path (initial load + background reconciliation) now uses the
fast variant. diffStat is null on initial load; it will be pushed later via
workspace_update once a git-aware path triggers.
2026-04-07 09:35:07 +07:00
Samuel K
b2229a28b9 fix(server): reset session ID on query restart to prevent overwrite crash (#201)
* fix(server): reset session ID on query restart to prevent overwrite crash

When ensureQuery() restarts a query, the old claudeSessionId was retained.
If the new query landed on a different Claude session (e.g. after a hook or
reconnect), captureSessionIdFromMessage() would detect the mismatch and
throw a fatal "session ID overwrite" error, killing the agent mid-turn.

Reset claudeSessionId and persistence during query restart so the new
session can establish its own identity cleanly.

Closes #200

* fix(server): recover from session ID overwrite and load projects instantly

- Reset claudeSessionId unconditionally before every new query creation,
  not just on explicit restarts. Fixes unrecoverable crash loop where a
  pump failure left stale session ID, causing every subsequent query to
  immediately throw the overwrite error again.

- Decouple fetch_workspaces_request from reconciliation: serve registry
  snapshot immediately instead of awaiting git operations on all
  workspaces. Background reconciliation runs after response is sent and
  pushes workspace_update events for any changed workspaces (e.g. stale
  worktrees getting archived).

* fix(server): auto-resume Claude session after overwrite error to preserve history

When the query pump dies (e.g. after a session ID overwrite error), preserve
claudeSessionId so the next query automatically passes resume: sessionId to
the Claude SDK. Claude responds with the same session ID, the overwrite check
passes, and the conversation history is intact.

Previously claudeSessionId was reset unconditionally before every new query,
causing the next query to start a fresh session and lose the conversation.
Now it is only reset for explicit restarts (queryRestartNeeded), where a
fresh session is the intended behavior.

* fix(server): reset session ID before throwing overwrite error to break retry loop

When the overwrite error fired, claudeSessionId was left set to the old value.
Our auto-resume fix then passed resume: oldSessionId on every retry, Claude
returned yet another new session ID each time, and the overwrite error looped
indefinitely (same "Existing" UUID across all failures in the log).

Fix: reset claudeSessionId = null in both captureSessionIdFromMessage and
handleSystemMessage before throwing, so the next query starts a fresh session
instead of looping on a dead resume target.

Auto-resume still works for legitimate pump failures (network drops etc.)
since those exit without throwing, leaving claudeSessionId intact.

* fix(server): accept session ID change mid-stream instead of failing the turn

Hooks can cause Claude to restart with a new session ID mid-turn. Previously
both captureSessionIdFromMessage and handleSystemMessage threw a CRITICAL error
when the session ID changed, failing the turn and leaving the user with an
error message on what should have been a transparent hook execution.

Now both paths log a warning and accept the new session ID, allowing the turn
to continue uninterrupted through hook-triggered subprocess restarts.
2026-04-07 10:28:46 +08:00
thatdaveguy1
d888c8f126 fix(server): bypass Copilot ACP prompts in autopilot (#206)
* fix(server): bypass Copilot ACP prompts in autopilot

* Delete lessons.md

---------

Co-authored-by: Paseo Bot <paseo-bot@users.noreply.github.com>
2026-04-07 10:28:12 +08:00
Huss Martinez
102ef06c30 fix: show direct connection and pairing modal content on tablets (#211)
On tablet-sized layouts the welcome-screen modals for direct connection and pair link could collapse so only the header row remained visible.

Adjust the desktop modal card and scroll container flex behavior so the modal body can shrink within the available height and remain visible instead of being clipped out.
2026-04-07 10:26:53 +08:00
Illia Panasenko
5cb424b2e6 feat(app): add open in editor toolbar action (#209)
* feat(app): add open in editor toolbar action

* fix(app): normalize editor icon asset permissions

* chore(website): drop unrelated generated route tree change
2026-04-07 10:25:25 +08:00
Illia Panasenko
fd9dfb0cc8 feat: add side-by-side diff layout and whitespace toggle (#208) 2026-04-07 10:22:20 +08:00
Mohamed Boudra
03380cfad0 docs(changelog): make 0.1.49 release notes user-friendly 2026-04-07 00:23:42 +07:00
Mohamed Boudra
6ce0e1e91f docs: clarify stable release and compatibility guidance 2026-04-07 00:19:13 +07:00
github-actions[bot]
64c2515b94 fix: update lockfile signatures and Nix hash 2026-04-06 17:17:36 +00:00
Mohamed Boudra
e90241c445 chore(release): cut 0.1.49 2026-04-07 00:16:12 +07:00
Mohamed Boudra
06fbeb413b docs(changelog): prepare 0.1.49 release notes 2026-04-07 00:15:33 +07:00
Mohamed Boudra
390a3402ab Fix provider snapshot session hydration and agent scoping 2026-04-07 00:11:43 +07:00
Mohamed Boudra
27ddc95862 Remove agent status bar provider model fallback 2026-04-06 23:44:53 +07:00
Mohamed Boudra
c63240b18c feat(app): unified provider snapshot, resilient model selector, and UX polish
- Model selector is no longer disabled while providers load; opens
  immediately and streams available providers as they arrive
- Selecting a non-default provider on mobile now works correctly
- Provider icon added to model trigger in mobile preferences sheet
- Thinking icon (brain) added to thinking trigger in mobile preferences
- Model descriptions for OpenCode/Pi shown inline next to model name
  instead of in a tooltip
- Running agents and draft screen share a single provider/model cache,
  eliminating duplicate network requests
- Provider data is prefetched when entering a workspace so the model
  selector is instant on first open
2026-04-06 23:37:22 +07:00
Mohamed Boudra
a5aca2312b feat(website): add Copilot and Pi icons to hero supports section 2026-04-06 23:05:21 +07:00
Mohamed Boudra
e01a0abdf2 fix(app): wire atomic provider+model selection on mobile draft screen
The mobile preferences modal split CombinedModelSelector's onSelect into
separate onSelectProvider and onSelectModel calls, but onSelectProvider
was never passed from DraftAgentStatusBar — so selecting a non-cloud
provider was silently ignored while only the model ID updated.

Add onSelectProviderAndModel prop to ControlledStatusBar and use it on
the mobile path, matching the web path's atomic behavior.
2026-04-06 22:50:58 +07:00
github-actions[bot]
3397e6c589 fix: update lockfile signatures and Nix hash 2026-04-05 08:42:35 +00:00
Mohamed Boudra
c4cccf5bd2 chore(release): cut 0.1.48 2026-04-05 15:40:54 +07:00
Mohamed Boudra
0130a637c8 docs: add 0.1.48 changelog 2026-04-05 15:39:55 +07:00
Mohamed Boudra
8c2ea33da8 fix(app): restore input focus for running agents and align mobile model selector
- Add onDropdownClose callback to AgentStatusBar and wire through ControlledStatusBar
  to CombinedModelSelector so running agents focus input after any dropdown closes
- Fix mobile misalignment in model selector by adding marginHorizontal: spacing[1]
  to sectionHeading, drillDownRow, backButton, and providerSearchContainer to match
  ComboboxItem's mobile margins
2026-04-05 15:38:22 +07:00
Mohamed Boudra
0d88ce2270 feat(app): provider system overhaul with model selector, diagnostics, and UX polish
- Add CombinedModelSelector with two-level drill-down (providers → models),
  favorites section, search, and single-provider auto-drill mode
- Add provider snapshot system (ProviderSnapshotManager) for cached provider/model state
- Add provider diagnostics with resolved binary paths and versions across all providers
- Add StatusBadge shared component for consistent status visual language
- Add ProviderDiagnosticSheet for detailed provider health inspection
- Add desktop auto-focus on input after any status bar interaction (model, mode,
  thinking, features) using focusWithRetries
- Fix Combobox flicker on height change by switching to bottom-based CSS positioning
  once initial floating-ui position resolves
- Add dynamic dropdown height: Level 1 fits content, Level 2 calculates from model count
- Remove "Other providers" section from model selector (only show available providers)
2026-04-05 15:32:13 +07:00
Mohamed Boudra
6907f6e71d fix(desktop): resolve login shell environment at Electron startup
On macOS, apps launched from Finder/Dock inherit a minimal environment
(PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems:
1. Agent binaries like codex were not detected (findExecutable failed)
2. Terminals spawned by Paseo had no access to user-installed tools
   (node, bun, direnv — all "command not found")

Fix: at Electron startup, spawn the user's login shell and capture its
full environment via JSON.stringify(process.env) using UUID markers.
This is the same battle-tested approach VS Code uses. The resolved
environment is merged into process.env before the daemon starts, so
all child processes — agents, terminals, git operations — inherit the
correct environment automatically.

This replaces the previous approach of invoking resolveShellEnv() (via
the shell-env npm package) at multiple scattered call sites. Now there
is a single source of truth: process.env is enriched once at startup.

Changes:
- New: packages/desktop/src/login-shell-env.ts (VS Code approach)
- Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes
- Simplified: applyProviderEnv() and findExecutable() now trust process.env
2026-04-05 15:11:18 +07:00
Mohamed Boudra
84638ecd9c feat(app): submit question card answer on Enter key press 2026-04-05 14:54:03 +07:00
Mohamed Boudra
809e5fbbdc feat(app): Enter key confirms and sends dictation
When dictating and focused on an agent, pressing Enter now confirms
the dictation and sends the message, matching the behavior of clicking
the submit button.
2026-04-05 14:53:10 +07:00
Mohamed Boudra
a264058a30 Remove noisy agent toasts and debug logging
Remove the "Refreshing" and "Failed to refresh agent" toasts from the
agent panel — they fire frequently but are meaningless since sync
recovers transparently. The "Reconnecting..." toast for host disconnect
is preserved.

Strip debug console.log calls across the app: render tracking in
message/stream views, dependency change tracking in workspace screen,
terminal tab slot logging, and verbose audio engine/voice runtime
bridge stats logging. Legitimate console.error/warn in catch blocks
are kept.
2026-04-05 11:10:49 +07:00
Mohamed Boudra
702e2e7db9 Handle Codex request_user_input app-server questions
Fixes #195
2026-04-05 09:21:45 +07:00
Mohamed Boudra
c32bc847bf feat(app): restore reload action for agent tabs 2026-04-05 09:21:45 +07:00
github-actions[bot]
7a2d976081 fix: update lockfile signatures and Nix hash 2026-04-04 17:59:55 +00:00
Mohamed Boudra
2d9ca6983a chore(release): cut 0.1.47 2026-04-05 00:58:22 +07:00
Mohamed Boudra
e44e495481 Update CHANGELOG.md for 0.1.47 stable release 2026-04-05 00:57:03 +07:00
Mohamed Boudra
bedf792616 fix(voice): harden Electron speak TTS path 2026-04-05 00:53:50 +07:00
Mohamed Boudra
5db9942125 fix(desktop): restore daemon QR pairing output 2026-04-05 00:53:46 +07:00
Mohamed Boudra
a889dbcc28 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-05 00:14:41 +07:00
Mohamed Boudra
5c1e869bc5 fix: copy sherpa TTS samples to avoid external buffer crash
Same fix as the Silero VAD — sherpa-onnx returns Float32Arrays backed
by native memory which Node.js rejects as "External buffers are not
allowed". Copy into JS-managed memory before passing to audio pipeline.
2026-04-04 23:45:28 +07:00
Mohamed Boudra
a693ff560c fix: remove per-host "Add connection" button that blocked multi-host setups
Users connecting multiple daemons (e.g. local + remote via Tailscale) hit
a "belongs to X, not Y" error because the edit-host modal's Add Connection
button scoped new connections to the current host's server ID. Remove that
button and its supporting state so all connections go through the top-level
flow which has no server ID constraint.
2026-04-04 23:45:03 +07:00
github-actions[bot]
89baf7ff38 fix: update lockfile signatures and Nix hash 2026-04-04 16:00:01 +00:00
Mohamed Boudra
c65a851205 chore(release): cut 0.1.46 2026-04-04 22:57:53 +07:00
Mohamed Boudra
85acdbb05e Update CHANGELOG.md for 0.1.46 stable release 2026-04-04 22:57:45 +07:00
Mohamed Boudra
4e32f9b7bd fix: copy Silero VAD model out of asar so native code can read it
The bundled silero_vad.onnx lives inside Electron's app.asar which
native C++ (sherpa-onnx) cannot open via OS file I/O. On first voice
activation, copy the model to ~/.paseo/models/local-speech/silero-vad/
using Node.js fs (asar-aware) and point the native code there.
2026-04-04 22:11:54 +07:00
Mohamed Boudra
744ca7a2bc fix: send appVersion in probe client hello and update it on session resume
buildClientConfig in test-daemon-connection.ts never set appVersion, so
probe clients (which become the live client) sent hello without it. The
daemon's version gate then hid Pi/Copilot from all these sessions.

Also update appVersion on the Session when a client reconnects with a
newer version, so stale sessions don't stay gated forever.
2026-04-04 21:12:34 +07:00
Mohamed Boudra
3110bae209 fix(website): stack header vertically on mobile to prevent overflow 2026-04-04 21:06:35 +07:00
Mohamed Boudra
16efdb2c95 feat(website): add Copilot and Pi to homepage provider grid 2026-04-04 20:29:44 +07:00
Mohamed Boudra
1e9b7f1157 fix: make worktreeRoot backward-compatible for old clients/daemons
worktreeRoot was added as a required field in 9154f8fc, which breaks
old clients parsing new daemon responses and new clients parsing old
daemon responses. Made it .optional() with .transform() fallbacks.

Also added a critical rule to CLAUDE.md: schema changes must always
be backward-compatible in both directions.
2026-04-04 20:29:44 +07:00
Mohamed Boudra
018ebd5f29 Fix punycode deprecation warning in CLI and daemon entrypoints 2026-04-04 20:29:44 +07:00
github-actions[bot]
4706d9e3bd fix: update lockfile signatures and Nix hash 2026-04-04 12:22:20 +00:00
202 changed files with 13077 additions and 3368 deletions

View File

@@ -35,8 +35,43 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
strategy:
fail-fast: false
matrix:
@@ -219,7 +254,8 @@ jobs:
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
permissions:
contents: write
packages: read
@@ -285,7 +321,8 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
permissions:
contents: write
packages: read

View File

@@ -1,5 +1,89 @@
# Changelog
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
- Settings shows a Refresh button for providers and displays error details inline.
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
- Restarting an agent quickly could crash the session.
- Copilot no longer pauses for permission prompts in autopilot mode.
- Connection and pairing dialogs now display correctly on tablets.
- Orchestration errors from agents are now surfaced instead of silently lost.
- Diff stats no longer reset to zero when reconnecting.
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added

View File

@@ -45,6 +45,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Debugging

View File

@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
## Standard release (patch)
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash
npm run release:patch
```
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
## Manual step-by-step
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:publish # Publish to npm
@@ -122,6 +129,16 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
@@ -131,7 +148,7 @@ Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
@@ -150,6 +167,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully

View File

@@ -0,0 +1,68 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-KBkXlFzYesnNdRRchNzJtrDGQUsIFv9hYYf1TBnfWSA=";
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

169
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.45",
"version": "0.1.51",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.45",
"version": "0.1.51",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -15932,18 +15932,6 @@
"node": ">=0.10.0"
}
},
"node_modules/default-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -18545,35 +18533,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/expect": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
@@ -21859,18 +21818,6 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-symbol-description": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -22582,15 +22529,6 @@
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@@ -23319,6 +23257,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -26558,6 +26497,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -27464,18 +27404,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -27701,6 +27629,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
@@ -31124,50 +31053,6 @@
"node": ">=8"
}
},
"node_modules/shell-env": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz",
"integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==",
"license": "MIT",
"dependencies": {
"default-shell": "^2.0.0",
"execa": "^5.1.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shell-env/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/shell-env/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@@ -32100,15 +31985,6 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/strip-indent": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
@@ -35030,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.45",
"@getpaseo/highlight": "0.1.45",
"@getpaseo/server": "0.1.45",
"@getpaseo/expo-two-way-audio": "0.1.51",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/server": "0.1.51",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35156,11 +35032,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.45",
"@getpaseo/server": "0.1.45",
"@getpaseo/relay": "0.1.51",
"@getpaseo/server": "0.1.51",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35201,11 +35077,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.45",
"version": "0.1.51",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.45",
"@getpaseo/server": "0.1.45",
"@getpaseo/cli": "0.1.51",
"@getpaseo/server": "0.1.51",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35239,7 +35115,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.45",
"version": "0.1.51",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35440,7 +35316,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35466,7 +35342,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35482,14 +35358,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.45",
"@getpaseo/relay": "0.1.45",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/relay": "0.1.51",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35510,7 +35386,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"shell-env": "^4.0.3",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
@@ -35889,7 +35764,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.45",
"version": "0.1.51",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.45",
"version": "0.1.51",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.45",
"version": "0.1.51",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.45",
"@getpaseo/highlight": "0.1.45",
"@getpaseo/server": "0.1.45",
"@getpaseo/expo-two-way-audio": "0.1.51",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/server": "0.1.51",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -369,10 +369,41 @@ function AppContainer({
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const isCompactLayout = isCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile: isCompactLayout,
@@ -574,7 +605,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
})
.catch((error) => {
if (cancelled) return;
@@ -692,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname

View File

@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
);
return;
}
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
});
return () => {

View File

@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
return;
}
router.replace(buildHostOpenProjectRoute(serverId) as any);
router.replace(buildHostOpenProjectRoute(serverId));
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);

View File

@@ -58,7 +58,7 @@ export default function Index() {
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;

View File

@@ -6,8 +6,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
@@ -61,7 +59,7 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute",
width: 36,
height: 36,
borderColor: theme.colors.palette.blue[400],
borderColor: theme.colors.accent,
},
cornerTL: {
left: 0,
@@ -148,33 +146,16 @@ export default function PairScanScreen() {
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
const returnToSource = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
return;
}
if (source === "editHost" && targetServerId) {
@@ -190,7 +171,7 @@ export default function PairScanScreen() {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId) as any);
router.replace(buildHostSettingsRoute(settingsServerId));
}
},
[router, source, sourceServerId, targetServerId],
@@ -209,7 +190,7 @@ export default function PairScanScreen() {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId) as any);
router.replace(buildHostSettingsRoute(sourceServerId));
return;
}
router.replace("/" as any);
@@ -224,7 +205,6 @@ export default function PairScanScreen() {
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
if (pendingNameHost) return;
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
@@ -248,7 +228,7 @@ export default function PairScanScreen() {
return;
}
const { client } = await connectToDaemon(
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -259,13 +239,7 @@ export default function PairScanScreen() {
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
if (isNewHost) {
setPendingNameHost({ serverId: profile.serverId, hostname: null });
return;
}
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
returnToSource(profile.serverId);
} catch (error) {
@@ -276,7 +250,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -307,25 +281,6 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingNameHost.serverId;
setPendingNameHost(null);
returnToSource(serverId);
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
}}
/>
) : null}
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
@@ -359,7 +314,6 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing

View File

@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]);
if (!targetServerId) {

View File

@@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: 520,
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
@@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
desktopScroll: {
flex: 1,
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,

View File

@@ -218,6 +218,7 @@ export function AddHostModal({
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });

View File

@@ -12,6 +12,7 @@ import {
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -47,6 +48,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = {
id: string;
@@ -74,6 +76,8 @@ interface AgentInputAreaProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
@@ -103,6 +107,7 @@ export function AgentInputArea({
clearDraft,
autoFocus = false,
onAddImages,
onFocusInput,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
@@ -127,11 +132,15 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
};
}),
);
@@ -208,6 +217,21 @@ export function AgentInputArea({
onAddImages?.(addImages);
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
if (Platform.OS !== "web") return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
return el != null && document.activeElement === el;
},
});
}, []);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => {
onMessageSent?.();
@@ -402,6 +426,10 @@ export function AgentInputArea({
}
switch (action.id) {
case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
if (Platform.OS !== "web") {
messageInputRef.current?.focus();
@@ -440,8 +468,10 @@ export function AgentInputArea({
handlerId: keyboardHandlerIdRef.current,
actions: [
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
@@ -612,11 +642,30 @@ export function AgentInputArea({
</View>
);
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter
? agentState.contextWindowUsedTokens
: null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} />
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
);
return (
@@ -691,10 +740,12 @@ export function AgentInputArea({
disabled={isSubmitLoading}
isInputActive={isInputActive}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
@@ -766,6 +817,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,

View File

@@ -240,7 +240,7 @@ export function AgentList({
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route as any);
router.navigate(route);
},
[isActionSheetVisible, onAgentSelect],
);

View File

@@ -15,8 +15,8 @@ import {
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
@@ -51,7 +51,6 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
@@ -87,6 +87,7 @@ type ControlledAgentStatusBarProps = {
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
};
export interface DraftAgentStatusBarProps {
@@ -108,12 +109,14 @@ export interface DraftAgentStatusBarProps {
onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
}
function findOptionLabel(
@@ -200,6 +203,7 @@ function ControlledStatusBar({
modelOptions,
selectedModelId,
onSelectModel,
onSelectProviderAndModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
@@ -212,6 +216,7 @@ function ControlledStatusBar({
onToggleFavoriteModel,
features,
onSetFeature,
onDropdownClose,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -260,7 +265,7 @@ function ControlledStatusBar({
return null;
}
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const modelDisabled = disabled;
const SEARCH_THRESHOLD = 6;
@@ -331,8 +336,11 @@ function ControlledStatusBar({
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null);
if (!nextOpen) {
onDropdownClose?.();
}
},
[],
[onDropdownClose],
);
const handleSelectorPress = useCallback(
@@ -403,6 +411,7 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onClose={onDropdownClose}
/>
</View>
</TooltipTrigger>
@@ -555,9 +564,7 @@ function ControlledStatusBar({
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
@@ -642,15 +649,20 @@ function ControlledStatusBar({
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
if (onSelectProviderAndModel) {
onSelectProviderAndModel(selectedProviderId, modelId);
} else {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[
@@ -660,6 +672,7 @@ function ControlledStatusBar({
pointerEvents="none"
testID="agent-preferences-model"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
@@ -685,6 +698,7 @@ function ControlledStatusBar({
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -784,9 +798,7 @@ function ControlledStatusBar({
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
@@ -833,7 +845,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
@@ -847,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
}
: null;
}),
@@ -858,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
} = useProvidersSnapshot(serverId);
const snapshotModels = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {
return null;
}
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]);
const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
if (agent?.provider && snapshotModels) {
map.set(agent.provider, snapshotModels);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
}, [agent?.provider, snapshotModels]);
const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -1023,7 +1018,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
isModelLoading={snapshotIsLoading || snapshotIsFetching}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
);
@@ -1048,6 +1044,7 @@ export function DraftAgentStatusBar({
onSelectThinkingOption,
features,
onSetFeature,
onDropdownClose,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
@@ -1092,6 +1089,7 @@ export function DraftAgentStatusBar({
}}
isLoading={isAllModelsLoading}
disabled={disabled}
onClose={onDropdownClose}
/>
<ControlledStatusBar
provider={selectedProvider}
@@ -1103,6 +1101,7 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
</View>
@@ -1115,30 +1114,33 @@ export function DraftAgentStatusBar({
}));
return (
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
<>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => onSelectModel(modelId)}
onSelectProviderAndModel={onSelectProviderAndModel}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
</>
);
}

View File

@@ -26,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
AssistantMessage,
SpeakMessage,
UserMessage,
ActivityLog,
ToolCall,
@@ -38,7 +39,10 @@ import {
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type {
AgentPermissionAction,
AgentPermissionResponse,
} from "@server/server/agent/agent-sdk-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -178,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
router.navigate(route);
return;
}
@@ -264,44 +268,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -394,6 +360,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage
message={data.detail.input}
timestamp={item.timestamp.getTime()}
/>
);
}
return (
<ToolCall
toolName={data.name}
@@ -732,6 +713,29 @@ function PermissionRequestCard({
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
}
if (Array.isArray(request.actions) && request.actions.length > 0) {
return request.actions;
}
return [
{
id: "reject",
label: "Deny",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest ? "Implement" : "Accept",
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request]);
const planMarkdown = useMemo(() => {
if (!request) {
@@ -772,11 +776,11 @@ function PermissionRequestCard({
isPending: isResponding,
} = permissionMutation;
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
useEffect(() => {
resetPermissionMutation();
setRespondingAction(null);
setRespondingActionId(null);
}, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
@@ -790,6 +794,24 @@ function PermissionRequestCard({
},
[permission.agentId, permission.request.id, respondToPermission],
);
const handleActionPress = useCallback(
(action: AgentPermissionAction) => {
setRespondingActionId(action.id);
if (action.behavior === "allow") {
handleResponse({
behavior: "allow",
selectedActionId: action.id,
});
return;
}
handleResponse({
behavior: "deny",
selectedActionId: action.id,
message: "Denied by user",
});
},
[handleResponse],
);
if (request.kind === "question") {
return (
@@ -816,64 +838,48 @@ function PermissionRequestCard({
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
<Pressable
testID="permission-request-deny"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
disabled={isResponding}
>
{respondingAction === "deny" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={permissionStyles.optionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
Deny
</Text>
</View>
)}
</Pressable>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const testID =
action.behavior === "deny"
? "permission-request-deny"
: action.id === "accept" || action.id === "implement"
? "permission-request-accept"
: `permission-request-action-${action.id}`;
<Pressable
testID="permission-request-accept"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
disabled={isResponding}
>
{respondingAction === "accept" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={permissionStyles.optionContent}>
<Check size={14} color={theme.colors.foreground} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
Accept
</Text>
</View>
)}
</Pressable>
return (
<Pressable
key={action.id}
testID={testID}
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
);
})}
</View>
</>
);

View File

@@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
ArrowLeft,
ChevronDown,
@@ -15,10 +17,14 @@ import {
Search,
Star,
} from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
const IS_WEB = Platform.OS === "web";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import {
@@ -29,7 +35,8 @@ import {
type SelectorModelRow,
} from "./combined-model-selector.utils";
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
type SelectorView =
| { kind: "all" }
@@ -51,6 +58,7 @@ interface CombinedModelSelectorProps {
disabled: boolean;
isOpen: boolean;
}) => React.ReactNode;
onClose?: () => void;
disabled?: boolean;
}
@@ -67,7 +75,6 @@ interface SelectorContentProps {
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
onBack?: () => void;
}
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
@@ -99,6 +106,22 @@ function partitionRows(
return { favoriteRows, regularRows };
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): SelectorModelRow[] {
const favorites: SelectorModelRow[] = [];
const rest: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
} else {
rest.push(row);
}
}
return [...favorites, ...rest];
}
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
@@ -126,6 +149,7 @@ function ModelRow({
isSelected,
isFavorite,
disabled = false,
elevated = false,
onPress,
onToggleFavorite,
}: {
@@ -133,12 +157,12 @@ function ModelRow({
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider);
const isWeb = Platform.OS === "web";
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
@@ -148,13 +172,18 @@ function ModelRow({
[onToggleFavorite, row.modelId, row.provider],
);
const item = (
const showDescription =
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
return (
<ComboboxItem
label={row.modelLabel}
description={showDescription ? row.description : undefined}
selected={isSelected}
disabled={disabled}
elevated={elevated}
onPress={onPress}
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
@@ -187,21 +216,6 @@ function ModelRow({
}
/>
);
if (!isWeb || !row.description) {
return item;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>{item}</View>
</TooltipTrigger>
<TooltipContent side="right" align="center" offset={4}>
<Text style={styles.tooltipText}>{row.description}</Text>
</TooltipContent>
</Tooltip>
);
}
function FavoritesSection({
@@ -228,7 +242,7 @@ function FavoritesSection({
}
return (
<View>
<View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
@@ -239,11 +253,11 @@ function FavoritesSection({
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
elevated
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
<View style={styles.separator} />
</View>
);
}
@@ -258,6 +272,7 @@ function GroupedProviderRows({
canSelectProvider,
onToggleFavorite,
onDrillDown,
viewKind,
}: {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
@@ -268,6 +283,7 @@ function GroupedProviderRows({
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) {
const { theme } = useUnistyles();
@@ -276,19 +292,14 @@ function GroupedProviderRows({
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
const isInline = viewKind === "provider";
return (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
@@ -309,11 +320,13 @@ function GroupedProviderRows({
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
@@ -324,6 +337,48 @@ function GroupedProviderRows({
);
}
function ProviderSearchInput({
value,
onChangeText,
autoFocus = false,
}: {
value: string;
onChangeText: (text: string) => void;
autoFocus?: boolean;
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus]);
return (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
);
}
function SelectorContent({
view,
providerDefinitions,
@@ -337,8 +392,8 @@ function SelectorContent({
canSelectProvider,
onToggleFavorite,
onDrillDown,
onBack,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions],
@@ -363,35 +418,41 @@ function SelectorContent({
[favoriteKeys, visibleRows],
);
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
// Group ALL visible rows by provider — favorites are a cross-cutting view,
// not a partition. A model being favorited doesn't remove it from its provider.
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
// When searching at Level 1, filter grouped rows to only providers whose name or models match
const filteredGroupedRows = useMemo(() => {
if (view.kind === "provider" || !normalizedQuery) {
return allGroupedRows;
}
return allGroupedRows.filter(
(group) =>
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
);
}, [allGroupedRows, normalizedQuery, view.kind]);
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
return (
<View>
{view.kind === "provider" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
{view.kind === "all" ? (
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null}
<SearchInput
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows}
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
@@ -399,12 +460,13 @@ function SelectorContent({
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
viewKind={view.kind}
/>
) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
{!hasResults ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
@@ -437,8 +499,8 @@ function ProviderBackButton({
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
@@ -455,6 +517,7 @@ export function CombinedModelSelector({
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
onClose,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
@@ -465,25 +528,35 @@ export function CombinedModelSelector({
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
// Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => {
const providers = Array.from(allProviderModels.keys());
if (providers.length !== 1) return null;
const providerId = providers[0]!;
const label = resolveProviderLabel(providerDefinitions, providerId);
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
setView({ kind: "all" });
setView(singleProviderView ?? { kind: "all" });
if (!open) {
setSearchQuery("");
onClose?.();
}
},
[],
[onClose, singleProviderView],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView({ kind: "all" });
setView(singleProviderView ?? { kind: "all" });
setSearchQuery("");
},
[onSelect],
[onSelect, singleProviderView],
);
const ProviderIcon = getProviderIcon(selectedProvider);
@@ -501,6 +574,15 @@ export function CombinedModelSelector({
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const models = allProviderModels.get(view.providerId);
const modelCount = models?.length ?? 0;
return Math.min(80 + modelCount * 40, 400);
}, [allProviderModels, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
return selectedModelLabel;
@@ -568,7 +650,30 @@ export function CombinedModelSelector({
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight}
title="Select model"
stickyHeader={
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
/>
</View>
) : undefined
}
>
{isContentReady ? (
<SelectorContent
@@ -586,13 +691,6 @@ export function CombinedModelSelector({
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -634,17 +732,23 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
height: "auto",
},
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
sectionHeadingText: {
fontSize: theme.fontSize.xs,
@@ -658,6 +762,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 36,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
drillDownRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -667,8 +772,8 @@ const styles = StyleSheet.create((theme) => ({
},
drillDownText: {
flex: 1,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
drillDownTrailing: {
flexDirection: "row",
@@ -679,6 +784,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
level2Header: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
@@ -687,9 +797,10 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
backButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface2,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
@@ -720,10 +831,6 @@ const styles = StyleSheet.create((theme) => ({
favoriteButtonPressed: {
backgroundColor: theme.colors.surface1,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
sheetLoadingState: {
minHeight: 160,
justifyContent: "center",
@@ -734,4 +841,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
providerSearchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[2],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
providerSearchInput: {
flex: 1,
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -0,0 +1,153 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ContextWindowMeterProps = {
maxTokens: number;
usedTokens: number;
};
const SVG_SIZE = 20;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidUsedTokens(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
return null;
}
return (usedTokens / maxTokens) * 100;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
}
return { progress: theme.colors.foregroundMuted, track };
}
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</Svg>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
svg: {
transform: [{ rotate: "-90deg" }],
},
tooltipContent: {
gap: theme.spacing[1],
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -118,7 +118,6 @@ export function FileExplorerPane({
);
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFileDownloadToken,
selectExplorerEntry,
@@ -129,6 +128,16 @@ export function FileExplorerPane({
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setExpandedPathsForWorkspace = usePanelStore(
(state) => state.setExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
[expandedPathsArray],
);
const directories = explorerState?.directories ?? new Map();
const pendingRequest = explorerState?.pendingRequest ?? null;
@@ -144,7 +153,6 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
@@ -154,8 +162,7 @@ export function FileExplorerPane({
useEffect(() => {
hasInitializedRef.current = false;
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
}, [workspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
@@ -169,23 +176,35 @@ export function FileExplorerPane({
recordHistory: false,
setCurrentPath: false,
});
}, [hasWorkspaceScope, requestDirectoryListing]);
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => {
if (!selectedEntryPath || !hasWorkspaceScope) {
if (!selectedEntryPath || !workspaceStateKey) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir);
setExpandedPaths((prev) => {
const next = new Set(prev);
ancestors.forEach((path) => next.add(path));
return next;
});
ancestors.forEach((path) => {
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
if (newPaths.length === 0) {
return;
}
setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), ...newPaths],
);
newPaths.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
recordHistory: false,
@@ -193,34 +212,46 @@ export function FileExplorerPane({
});
}
});
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
}, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!hasWorkspaceScope) {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
const nextExpanded = !isExpanded;
setExpandedPaths((prev) => {
const next = new Set(prev);
if (isExpanded) {
next.delete(entry.path);
} else {
next.add(entry.path);
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
} else {
setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), entry.path],
);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
return next;
});
if (nextExpanded && !directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[
workspaceStateKey,
expandedPaths,
directories,
requestDirectoryListing,
setExpandedPathsForWorkspace,
],
);
const handleOpenFile = useCallback(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { SquareTerminal } from "lucide-react-native";
import { Image, type ImageSourcePropType } from "react-native";
import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@server/shared/messages";
interface EditorAppIconProps {
editorId: EditorTargetId;
size?: number;
color?: string;
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(
editorId: EditorTargetId,
): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
}
export function EditorAppIcon({
editorId,
size = 16,
color,
}: EditorAppIconProps) {
if (!hasBundledEditorAppIcon(editorId)) {
return <SquareTerminal size={size} color={color} />;
}
return (
<Image
source={EDITOR_APP_IMAGES[editorId]}
style={{ width: size, height: size }}
resizeMode="contain"
/>
);
}

View File

@@ -229,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
return;
}
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any);
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSessionsRoute(activeServerId) as any);
router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]);
const handleHostSelect = useCallback(
@@ -253,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
router.push(nextPath);
},
[pathname],
);

View File

@@ -79,12 +79,17 @@ export interface MessageInputProps {
isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode;
voiceServerId?: string;
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
/** Controls what the default send action (Enter, send button, dictation) does
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
defaultSendBehavior?: "interrupt" | "queue";
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -100,7 +105,7 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
@@ -201,10 +206,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled = false,
isInputActive = true,
leftContent,
beforeVoiceContent,
rightContent,
voiceServerId,
voiceAgentId,
isAgentRunning = false,
defaultSendBehavior = "interrupt",
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
@@ -242,26 +249,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
runKeyboardAction: (action) => {
if (action === "focus") {
textInputRef.current?.focus();
return;
return true;
}
if (action === "send" || action === "dictation-confirm") {
if (isDictatingRef.current) {
sendAfterTranscriptRef.current = true;
confirmDictation();
return true;
}
return false;
}
if (action === "voice-toggle") {
handleToggleRealtimeVoiceShortcut();
return;
return true;
}
if (action === "voice-mute-toggle") {
if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute();
}
return;
return true;
}
if (action === "dictation-cancel") {
if (isDictatingRef.current) {
cancelDictation();
}
return;
return true;
}
if (action === "dictation-toggle") {
@@ -271,7 +287,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} else {
void startDictationIfAvailable();
}
return true;
}
return false;
},
getNativeElement: () => {
if (!IS_WEB) return null;
@@ -337,11 +356,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
// Respect send behavior setting: when "queue", dictation queues too.
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
onQueue({ text: nextValue, images: imageAttachments });
onChangeText("");
} else {
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
}
} else {
onChangeText(nextValue);
}
@@ -352,7 +377,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
});
}
},
[onChangeText, onSubmit, images, isAgentRunning],
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -563,6 +588,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]);
// Default send action: respects the sendBehavior setting.
// When "interrupt" (default), primary action sends immediately (interrupts).
// When "queue", primary action queues when agent is running.
const handleDefaultSendAction = useCallback(() => {
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
handleQueueMessage();
} else {
handleSendMessage();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
// Alternate send action: always the opposite of the default.
const handleAlternateSendAction = useCallback(() => {
if (defaultSendBehavior === "queue") {
handleSendMessage(); // interrupt
} else if (onQueue) {
handleQueueMessage(); // queue
}
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
@@ -857,18 +902,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return;
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleQueueMessage();
handleAlternateSendAction();
return;
}
// Enter: send (interrupts agent if running)
// Enter: default send action (interrupt or queue, based on setting)
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleSendMessage();
handleDefaultSendAction();
}
const hasImages = images.length > 0;
@@ -877,11 +922,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: isAgentRunning
? "Send and interrupt"
: "Send message";
: defaultActionQueues
? "Queue message"
: isAgentRunning
? "Send and interrupt"
: "Send message";
const handleInputChange = useCallback(
(nextValue: string) => {
@@ -1003,6 +1051,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
@@ -1055,10 +1104,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && (
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
onPress={handleAlternateSendAction}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
@@ -1083,7 +1132,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
@@ -1097,7 +1146,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>

View File

@@ -39,6 +39,7 @@ import {
Copy,
TriangleAlertIcon,
Scissors,
MicVocal,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import Animated, {
@@ -717,13 +718,6 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
@@ -920,6 +914,65 @@ export const AssistantMessage = memo(function AssistantMessage({
);
});
interface SpeakMessageProps {
message: string;
timestamp: number;
disableOuterSpacing?: boolean;
}
const speakMessageStylesheet = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
},
containerSpacing: {
marginBottom: theme.spacing[4],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
headerLabel: {
fontFamily: Fonts.sans,
fontSize: 12,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
text: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
}));
export const SpeakMessage = memo(function SpeakMessage({
message,
timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
return (
<View
testID="speak-message"
style={[
speakMessageStylesheet.container,
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
]}
>
<View style={speakMessageStylesheet.header}>
<MicVocal size={14} color={theme.colors.foregroundMuted} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
</View>
);
});
interface ActivityLogProps {
type: "system" | "info" | "success" | "error" | "artifact";
message: string;
@@ -1765,9 +1818,6 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);

View File

@@ -1,117 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
field: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[4],
},
}));
export interface NameHostModalProps {
visible: boolean;
serverId: string;
hostname: string | null;
onSkip: () => void;
onSave: (label: string) => void;
}
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
const { theme } = useUnistyles();
const [label, setLabel] = useState("");
const hasEditedRef = useRef(false);
const suggested = (hostname?.trim() || serverId).trim();
useEffect(() => {
if (!visible) return;
setLabel(suggested);
hasEditedRef.current = false;
}, [suggested, visible]);
useEffect(() => {
if (!visible) return;
if (hasEditedRef.current) return;
if (!hostname) return;
const trimmed = label.trim();
if (trimmed.length === 0 || trimmed === serverId) {
setLabel(hostname.trim());
}
}, [hostname, label, serverId, visible]);
const handleChange = useCallback((value: string) => {
hasEditedRef.current = true;
setLabel(value);
}, []);
const handleSave = useCallback(() => {
const trimmed = label.trim();
if (!trimmed) {
onSkip();
return;
}
onSave(trimmed);
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
<AdaptiveTextInput
value={label}
onChangeText={handleChange}
placeholder={suggested}
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</View>
<View style={styles.actions}>
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
Skip
</Button>
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
Save
</Button>
</View>
</AdaptiveModalSheet>
);
}

View File

@@ -146,7 +146,7 @@ export function PairLinkModal({
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {

View File

@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
interface ProviderDiagnosticSheetProps {
provider: string;
visible: boolean;
onClose: () => void;
serverId: string;
}
export function ProviderDiagnosticSheet({
provider,
visible,
onClose,
serverId,
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
setLoading(true);
setDiagnostic(null);
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
}, [client, provider]);
useEffect(() => {
if (visible) {
fetchDiagnostic();
} else {
setDiagnostic(null);
}
}, [visible, fetchDiagnostic]);
return (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
</ScrollView>
) : null}
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
loadingContainer: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
loadingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4],
},
diagnosticText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: "monospace",
lineHeight: theme.fontSize.sm * 1.6,
},
}));

View File

@@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
});
function handleSubmit() {
if (!allAnswered || isResponding) return;
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
@@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
placeholderTextColor={theme.colors.foregroundMuted}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
onSubmitEditing={handleSubmit}
editable={!isResponding}
blurOnSubmit={false}
/>
</View>
);

View File

@@ -1113,7 +1113,7 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any,
}),
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);

View File

@@ -84,6 +84,7 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -176,18 +177,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
@@ -265,6 +254,7 @@ export function SplitContainer({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -534,6 +524,7 @@ export function SplitContainer({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -656,6 +647,7 @@ function SplitNodeView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -691,6 +683,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -741,6 +734,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -790,6 +784,7 @@ function SplitPaneView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -901,6 +896,7 @@ function SplitPaneView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}

View File

@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
ListHeaderComponent={liveHeaderContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}

View File

@@ -73,6 +73,12 @@ export interface ComboboxProps {
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean;
/** Minimum width for the desktop popover (overrides trigger-based width). */
desktopMinWidth?: number;
/** Fixed height for the desktop popover (overrides default 400px max). */
desktopFixedHeight?: number;
/** Content rendered above the scroll area on desktop (sticky header). */
stickyHeader?: ReactNode;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
@@ -150,6 +156,8 @@ export interface ComboboxItemProps {
selected?: boolean;
active?: boolean;
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
onPress: () => void;
testID?: string;
}
@@ -163,6 +171,7 @@ export function ComboboxItem({
selected,
active,
disabled,
elevated,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -187,19 +196,19 @@ export function ComboboxItem({
onPress={onPress}
style={({ pressed, hovered = false }) => [
styles.comboboxItem,
hovered && styles.comboboxItemHovered,
pressed && styles.comboboxItemPressed,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
]}
>
{leadingContent}
<View style={styles.comboboxItemContent}>
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null}
@@ -246,6 +255,9 @@ export function Combobox({
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
desktopFixedHeight,
stickyHeader,
anchorRef,
children,
}: ComboboxProps): ReactElement {
@@ -390,12 +402,27 @@ export function Combobox({
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
!isDesktopAboveSearch &&
IS_WEB &&
!isMobile &&
hasResolvedDesktopPosition &&
desktopPlacement.startsWith("top") &&
referenceTop !== null;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles;
: useStableBottom
? {
left: floatingLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
@@ -662,6 +689,7 @@ export function Combobox({
<View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
{stickyHeader}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
@@ -687,13 +715,16 @@ export function Combobox({
styles.desktopContainer,
{
position: "absolute",
minWidth: referenceWidth ?? 200,
maxWidth: 400,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: Math.max(400, desktopMinWidth ?? 0),
},
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) }
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
: null,
]}
ref={refs.setFloating}
@@ -701,14 +732,17 @@ export function Combobox({
onLayout={() => update()}
>
{children ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
<>
{stickyHeader}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
</>
) : (
<>
{effectiveOptionsPosition === "above-search" ? (
@@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemHovered: {
backgroundColor: theme.colors.surface1,
},
comboboxItemHoveredElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemPressed: {
backgroundColor: theme.colors.surface1,
},
comboboxItemPressedElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemActive: {
backgroundColor: theme.colors.surface1,
},
@@ -807,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
flexShrink: 1,
},
comboboxItemContentInline: {
flexDirection: "row",
alignItems: "baseline",
gap: theme.spacing[2],
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: "center",
@@ -817,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
comboboxItemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
emptyText: {
paddingHorizontal: theme.spacing[3],
@@ -876,6 +921,9 @@ const styles = StyleSheet.create((theme) => ({
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
desktopChildrenScrollContent: {
// No padding — custom children (e.g. model selector) control their own spacing
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",

View File

@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -595,6 +596,7 @@ export function ContextMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -612,6 +614,7 @@ export function ContextMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
@@ -642,7 +645,7 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -704,6 +707,19 @@ export function ContextMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -27,6 +27,7 @@ import {
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,65 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type StatusBadgeVariant = "success" | "error" | "muted";
interface StatusBadgeProps {
label: string;
variant?: StatusBadgeVariant;
}
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
const { theme } = useUnistyles();
return (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
pill: {
flexDirection: "row",
alignItems: "center",
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
paddingHorizontal: theme.spacing[2],
paddingVertical: 3,
},
pillSuccess: {
backgroundColor: theme.colors.palette.green[900],
borderColor: theme.colors.palette.green[800],
},
pillError: {
backgroundColor: theme.colors.palette.red[900],
borderColor: theme.colors.palette.red[800],
},
pillText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
pillTextSuccess: {
color: theme.colors.palette.green[400],
},
pillTextError: {
color: theme.colors.palette.red[500],
},
}));

View File

@@ -44,6 +44,7 @@ type TooltipContextValue = {
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
};
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
});
}
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({
triggerRect,
contentSize,
@@ -214,12 +227,8 @@ export function Tooltip({
onOpenChange,
});
const isWeb = Platform.OS === "web";
const isMobileWeb =
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const isMobile = isMobileTooltipEnvironment();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -227,9 +236,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration],
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -323,9 +333,17 @@ export function TooltipTrigger({
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close();
},
[close, onPress],
[clearOpenTimer, close, ctx, disabled, onPress],
);
const triggerProps = {
@@ -492,7 +510,7 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -513,7 +531,7 @@ export function TooltipContent({
>
{children}
</Animated.View>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -8,14 +8,11 @@ import type { HostProfile } from "@/types/host-connection";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
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 { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
@@ -237,45 +234,23 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
},
[router],
);
@@ -396,13 +371,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId, hostname, isNewHost }) => {
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId);
}}
/>
@@ -410,38 +380,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId, hostname, isNewHost }) => {
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId);
}}
/>
{pendingNameHost && pendingRedirectServerId ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingRedirectServerId;
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);
}}
onSave={(label) => {
const serverId = pendingRedirectServerId;
void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);
});
}}
/>
) : null}
</ScrollView>
);
}

View File

@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
processTimelineResponse,
@@ -32,6 +33,7 @@ import {
type Agent,
type SessionState,
type WorkspaceDescriptor,
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
@@ -67,6 +69,22 @@ export type {
const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
currentUsage: Agent["lastUsage"] | undefined,
): boolean {
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
"inputTokens",
"outputTokens",
"cachedInputTokens",
"totalCostUsd",
"contextWindowMaxTokens",
"contextWindowUsedTokens",
];
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
interface BufferedAudioChunk {
@@ -309,6 +327,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
const workspaces = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false;
@@ -324,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set(workspace.id, workspace);
workspaces.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
@@ -349,6 +374,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
if (hasUsageUpdate) {
const next = new Map(prev);
next.set(agent.id, {
...current,
lastUsage: agent.lastUsage,
});
return next;
}
return prev;
}
const next = new Map(prev);
@@ -603,6 +637,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
updateSessionClient(serverId, client);
}, [serverId, client, updateSessionClient]);
useEffect(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return;
}
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
if (!isConnected) {
return;
}
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo?.features?.providersSnapshot) {
return;
}
prefetchProvidersSnapshot(serverId, client);
}, [client, isConnected, serverId]);
useEffect(() => {
if (!voiceRuntime) {
return;
@@ -1105,6 +1167,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
return;
}

View File

@@ -1,8 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import { useCallback, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import {
@@ -12,7 +10,6 @@ import {
RotateCw,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
@@ -23,17 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
export interface LocalDaemonSectionProps {
appVersion: string | null;
@@ -44,48 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void loadDaemonData();
return undefined;
}, [loadDaemonData, showSection]),
);
const daemonStatus = data?.status ?? null;
const daemonLogs = data?.logs ?? null;
const daemonVersion = daemonStatus?.version ?? null;
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
@@ -124,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action()
.then((status) => {
setDaemonStatus(status);
.then((newStatus) => {
setStatus(newStatus);
setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
);
return loadDaemonData();
refetch();
})
.catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", error);
@@ -144,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -190,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadDaemonData())
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped.");
})
.catch((error) => {
@@ -210,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadDaemonData,
refetch,
setStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
@@ -238,46 +206,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
setIsLogsModalOpen(true);
}, [daemonLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
@@ -325,154 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings
</Button>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
{isLoading ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
{showLifecycleControls ? (
<>
) : (
<>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
{daemonLogs?.logPath ?? "Log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
) : null}
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
) : null}
</>
)}
<AdaptiveModalSheet
visible={isLogsModalOpen}
@@ -516,107 +428,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: DesktopPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={settingsStyles.rowHint}>Loading pairing offer</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
actionGroup: {
flexDirection: "row",
@@ -624,6 +435,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
justifyContent: "flex-end",
},
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,
@@ -659,40 +475,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
width: "100%",
aspectRatio: 1,
alignSelf: "stretch",
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: "100%",
height: "100%",
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -0,0 +1,187 @@
import { useCallback } from "react";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useQuery } from "@tanstack/react-query";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw, Copy, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useState } from "react";
export function PairDeviceSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [copied, setCopied] = useState(false);
const pairingQuery = useQuery({
queryKey: ["desktop-daemon-pairing"],
queryFn: getDesktopDaemonPairing,
enabled: showSection,
staleTime: 5 * 60 * 1000,
retry: 1,
});
const qrQuery = useQuery({
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
queryFn: () =>
QRCode.toDataURL(pairingQuery.data!.url!, {
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: !!pairingQuery.data?.url,
staleTime: Infinity,
});
const handleCopyLink = useCallback(async () => {
if (!pairingQuery.data?.url) return;
await Clipboard.setStringAsync(pairingQuery.data.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [pairingQuery.data?.url]);
if (!showSection) return null;
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
<View style={settingsStyles.card}>
{pairingQuery.isPending ? (
<View style={styles.centered}>
<ActivityIndicator size="small" />
<Text style={styles.hint}>Loading pairing offer</Text>
</View>
) : pairingQuery.isError ? (
<View style={styles.centered}>
<Text style={styles.hint}>
{pairingQuery.error instanceof Error
? pairingQuery.error.message
: "Failed to load pairing offer."}
</Text>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void pairingQuery.refetch()}
>
Retry
</Button>
</View>
) : !pairingQuery.data?.url ? (
<View style={styles.centered}>
<Text style={styles.hint}>
{pairingQuery.data?.relayEnabled === false
? "Relay is not enabled. Enable relay to pair a device."
: "Pairing offer unavailable."}
</Text>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void pairingQuery.refetch()}
>
Retry
</Button>
</View>
) : (
<View style={styles.content}>
<Text style={styles.hint}>
Scan this QR code with Paseo on your phone, or copy the link below.
</Text>
<View style={styles.qrContainer}>
{qrQuery.data ? (
<Image source={{ uri: qrQuery.data }} style={styles.qrImage} resizeMode="contain" />
) : qrQuery.isError ? (
<Text style={styles.hint}>QR code unavailable.</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.linkInput}
value={pairingQuery.data.url}
readOnly
selectTextOnFocus
selectionColor={theme.colors.accent}
/>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
copied ? (
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
) : (
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={() => void handleCopyLink()}
>
{copied ? "Copied" : "Copy"}
</Button>
</View>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
centered: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
},
content: {
gap: theme.spacing[3],
padding: theme.spacing[4],
},
hint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textAlign: "center",
},
qrContainer: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
width: 320,
height: 320,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[2],
},
qrImage: {
width: "100%",
height: "100%",
},
linkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inputWrapper: {
flex: 1,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
linkInput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
outlineStyle: "none",
} as any,
}));

View File

@@ -0,0 +1,53 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getDesktopDaemonLogs,
getDesktopDaemonStatus,
shouldUseDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
interface DaemonStatusData {
status: DesktopDaemonStatus;
logs: DesktopDaemonLogs;
}
export function useDaemonStatus() {
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
const query = useQuery<DaemonStatusData>({
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return { status, logs };
},
});
const setStatus = useCallback(
(status: DesktopDaemonStatus) => {
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
prev ? { ...prev, status } : undefined,
);
},
[queryClient],
);
const refetch = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
}, [queryClient]);
return {
data: query.data ?? null,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : null,
setStatus,
refetch,
};
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueries } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -8,9 +7,10 @@ import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
mergeProviderPreferences,
@@ -84,6 +84,7 @@ type UseAgentFormStateResult = {
providerDefinitions: AgentProviderDefinition[];
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
agentDefinition?: AgentProviderDefinition;
allProviderEntries?: ProviderSnapshotEntry[];
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
@@ -368,141 +369,61 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}
}, [isVisible]);
// Session state for provider model listing
const client = useHostRuntimeClient(formState.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
error: snapshotError,
refresh: refreshSnapshot,
} = useProvidersSnapshot(formState.serverId);
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(
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => {
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries]);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
),
[providerDefinitions],
[snapshotProviderDefinitions],
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const availableModels = providerModelsQuery.data ?? null;
const providerModesQuery = useQuery({
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModes(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.modes ?? [];
},
});
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(def.id as AgentProvider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const allProviderModels = useMemo(() => {
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
const readyProviders = new Set(
(snapshotEntries ?? [])
.filter((entry) => entry.status === "ready")
.map((entry) => entry.provider),
);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < providerDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(providerDefinitions[i]!.id, query.data);
}
for (const entry of snapshotEntries ?? []) {
map.set(entry.provider, entry.models ?? []);
}
return map;
}, [allProviderModelQueries, providerDefinitions]);
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
}, [snapshotEntries]);
const snapshotSelectedEntry = useMemo(
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
[formState.provider, snapshotEntries],
);
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
const snapshotSelectedProviderModes =
snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
[];
const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap;
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -527,7 +448,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
userModified,
formStateRef.current,
validServerIds,
providerDefinitionMap,
selectableProviderDefinitionMap,
);
// Only update if something changed
@@ -552,7 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
availableModels,
userModified,
validServerIds,
providerDefinitionMap,
selectableProviderDefinitionMap,
]);
// Auto-select the first online host when:
@@ -592,8 +513,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerModels = allProviderModels.get(provider) ?? null;
const providerDef = providerDefinitionMap.get(provider);
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
const isValidModel = (m: string) =>
@@ -631,12 +555,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId,
}));
},
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
[
allProviderModels,
preferences?.providerPreferences,
selectableProviderDefinitionMap,
updatePreferences,
],
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
@@ -656,7 +588,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
},
[allProviderModels, providerDefinitionMap, updatePreferences],
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
);
const setModeFromUser = useCallback(
@@ -713,8 +645,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, []);
const refreshProviderModels = useCallback(() => {
void providerModelsQuery.refetch();
}, [providerModelsQuery]);
refreshSnapshot();
}, [refreshSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
@@ -747,13 +679,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -776,6 +706,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
providerDefinitions,
providerDefinitionMap,
agentDefinition,
allProviderEntries,
modeOptions,
availableModels: availableModels ?? [],
allProviderModels,
@@ -806,6 +737,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
providerDefinitions,
providerDefinitionMap,
agentDefinition,
allProviderEntries,
modeOptions,
availableModels,
allProviderModels,

View File

@@ -9,7 +9,6 @@ import {
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
@@ -74,7 +73,6 @@ function createBaseMemory(
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
it("uses silent catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
expect(sync.ui).toBe("silent");
});
it("keeps sync errors non-blocking once the screen was ready", () => {
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(ready);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(errorReady);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expectSyncErrorSync(ready);
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("closed");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.ui).toBe("silent");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});

View File

@@ -39,12 +39,9 @@ function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInpu
);
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: AgentScreenAgent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
@@ -54,17 +51,8 @@ export type AgentScreenReadySyncState =
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
| { status: "sync_error" };
export type AgentScreenViewState =
| {
@@ -98,7 +86,6 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
@@ -180,45 +167,23 @@ export function deriveAgentScreenViewState({
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
sync = { status: "sync_error" };
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
let ui: "overlay" | "toast" | "silent";
let ui: "overlay" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
ui = "silent";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
sync = { status: "catching_up", ui };
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
@@ -245,7 +210,6 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
@@ -254,7 +218,6 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-changes-preferences", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults to unified layout with visible whitespace", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:changes-preferences",
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
);
});
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "diff-wrap-lines") {
return "true";
}
return null;
});
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "unified",
wrapLines: true,
hideWhitespace: false,
});
});
it("loads persisted layout and whitespace preferences", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:changes-preferences") {
return JSON.stringify({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
}
return null;
});
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,89 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
wrapLines: false,
hideWhitespace: false,
};
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
if (legacyValue === "true") {
return true;
}
if (legacyValue === "false") {
return false;
}
return null;
}
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
if (stored) {
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
}
const legacyWrapLines = await loadLegacyWrapLinesPreference();
const next = {
...DEFAULT_CHANGES_PREFERENCES,
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
} satisfies ChangesPreferences;
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
return next;
}
export interface UseChangesPreferencesReturn {
preferences: ChangesPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
}
export function useChangesPreferences(): UseChangesPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
queryFn: loadChangesPreferencesFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferences = useCallback(
async (updates: Partial<ChangesPreferences>) => {
const prev =
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...updates };
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return {
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
isLoading: isPending,
updatePreferences,
};
}

View File

@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
interface UseCheckoutDiffQueryOptions {
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffQuery({
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient();
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
[mode, baseRef],
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
[serverId, cwd, mode, baseRef],
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
...payload,
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);

View File

@@ -165,7 +165,7 @@ export function useCommandCenter() {
const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
}, [activeServerId]);
const actionItems = useMemo(() => {
@@ -220,7 +220,7 @@ export function useCommandCenter() {
workspaceId: agent.cwd,
target: { kind: "agent", agentId: agent.id },
});
router.navigate(route as any);
router.navigate(route);
},
[setOpen],
);

View File

@@ -1,70 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
import { ActivityIndicator } from "react-native";
import type { ToastShowOptions } from "@/components/toast-host";
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
interface UseDelayedHistoryRefreshToastParams {
isCatchingUp: boolean;
indicatorColor: string;
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
}
export function useDelayedHistoryRefreshToast({
isCatchingUp,
indicatorColor,
showToast,
}: UseDelayedHistoryRefreshToastParams): void {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasCatchingUpRef = useRef(false);
const isCatchingUpRef = useRef(false);
const showToastRef = useRef(showToast);
const indicatorColorRef = useRef(indicatorColor);
useEffect(() => {
showToastRef.current = showToast;
}, [showToast]);
useEffect(() => {
indicatorColorRef.current = indicatorColor;
}, [indicatorColor]);
useEffect(() => {
isCatchingUpRef.current = isCatchingUp;
const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp;
const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp;
if (enteredCatchUp) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (!isCatchingUpRef.current) {
return;
}
showToastRef.current("Refreshing", {
icon: <ActivityIndicator size="small" color={indicatorColorRef.current} />,
durationMs: HISTORY_REFRESH_TOAST_DURATION_MS,
testID: "agent-history-refresh-toast",
});
}, HISTORY_REFRESH_TOAST_DELAY_MS);
} else if (exitedCatchUp && timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
wasCatchingUpRef.current = isCatchingUp;
}, [isCatchingUp]);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, []);
}

View File

@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";

View File

@@ -129,6 +129,11 @@ export function useKeyboardShortcuts({
id: "message-input.focus",
scope: "message-input",
});
case "send":
return keyboardActionDispatcher.dispatch({
id: "message-input.send",
scope: "message-input",
});
case "dictation-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-toggle",
@@ -139,6 +144,11 @@ export function useKeyboardShortcuts({
id: "message-input.dictation-cancel",
scope: "message-input",
});
case "dictation-confirm":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-confirm",
scope: "message-input",
});
case "voice-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.voice-toggle",

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredEditorId } from "./use-preferred-editor";
describe("resolvePreferredEditorId", () => {
it("keeps the stored editor when it is still available", () => {
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
});
it("falls back to the first available editor when the stored one is missing", () => {
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
});
it("falls back when a platform-specific file manager target is unavailable", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
});
it("keeps unknown editor ids when they are still available", () => {
expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe(
"unknown-editor",
);
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
}
export function resolvePreferredEditorId(
availableEditorIds: readonly EditorTargetId[],
storedEditorId: EditorTargetId | null | undefined,
): EditorTargetId | null {
if (
storedEditorId &&
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
) {
return storedEditorId;
}
return availableEditorIds[0] ?? null;
}
export function usePreferredEditor() {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: PREFERRED_EDITOR_QUERY_KEY,
queryFn: loadPreferredEditor,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferredEditor = useCallback(
async (editorId: EditorTargetId | null) => {
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
if (editorId) {
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
return;
}
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
},
[queryClient],
);
return {
preferredEditorId: data ?? null,
isLoading: isPending,
updatePreferredEditor,
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { queryClient as singletonQueryClient } from "@/query/query-client";
export function providersSnapshotQueryKey(serverId: string | null) {
return ["providersSnapshot", serverId] as const;
}
interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
isFetching: boolean;
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
}
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsSnapshot = useSessionForServer(
serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
const snapshotQuery = useQuery({
queryKey,
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot();
},
});
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
queryClient.setQueryData(queryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
const refresh = useCallback(() => {
if (!client) {
return;
}
void client.refreshProvidersSnapshot();
}, [client]);
return {
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
isFetching: snapshotQuery.isFetching,
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,
};
}
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
const queryKey = providersSnapshotQueryKey(serverId);
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot(),
});
}

View File

@@ -6,14 +6,18 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendBehavior = "interrupt" | "queue";
export interface AppSettings {
theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
};
export interface UseAppSettingsReturn {

View File

@@ -1,5 +1,9 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
try {
while (true) {
@@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
next.set(workspace.id, workspace);
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;

View File

@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
*/
export function navigateToWorkspace(serverId: string, workspaceId: string) {
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href as any);
router.navigate(href);
}
export function useWorkspaceNavigation() {

View File

@@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind =
| "queue"
| "dictation-toggle"
| "dictation-cancel"
| "dictation-confirm"
| "voice-toggle"
| "voice-mute-toggle";

View File

@@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work
export type KeyboardActionId =
| "message-input.focus"
| "message-input.send"
| "message-input.dictation-toggle"
| "message-input.dictation-cancel"
| "message-input.dictation-confirm"
| "message-input.voice-toggle"
| "message-input.voice-mute-toggle"
| "workspace.tab.new"
@@ -27,8 +29,10 @@ export type KeyboardActionId =
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.send"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "workspace.tab.new"; scope: KeyboardActionScope }

View File

@@ -860,6 +860,14 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
id: "message-input-dictation-confirm-enter",
action: "message-input.action",
combo: "Enter",
when: { commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "dictation-confirm" },
},
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",

View File

@@ -21,7 +21,6 @@ import {
type AgentScreenMissingState,
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -704,27 +703,6 @@ function AgentPanelBody({
shouldUseOptimisticStream,
]);
const isHistoryRefreshCatchingUp =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "toast";
const shouldEmitSyncErrorToast =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.shouldEmitSyncErrorToast;
useDelayedHistoryRefreshToast({
isCatchingUp: isHistoryRefreshCatchingUp,
indicatorColor: theme.colors.primary,
showToast: panelToast.api.show,
});
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
panelToast.api.error("Failed to refresh agent. Retrying in background.");
}, [panelToast.api, shouldEmitSyncErrorToast]);
if (viewState.tag === "not_found") {
return (

View File

@@ -5,6 +5,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { ConnectionOffer } from "@server/shared/connection-offer";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
@@ -189,6 +190,17 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
};
}
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
return {
v: 2,
serverId: input?.serverId ?? "srv_offer",
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
},
};
}
function makeDeps(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
@@ -1295,4 +1307,53 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
store.syncHosts([]);
});
it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertRelayConnection({
serverId: "srv_offer",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test_offer",
label: "Custom name",
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("Custom name");
store.syncHosts([]);
});
});

View File

@@ -1287,15 +1287,22 @@ export class HostRuntimeStore {
});
}
async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
): Promise<HostProfile> {
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});
}
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
): Promise<HostProfile> {
const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker);
if (idx === -1) {
@@ -1307,7 +1314,7 @@ export class HostRuntimeStore {
}
const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload);
return this.upsertConnectionFromOffer(offer);
return this.upsertConnectionFromOffer(offer, label);
}
async addConnectionFromListenAndWaitForOnline(input: {
@@ -1956,8 +1963,11 @@ export interface HostMutations {
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
label?: string,
) => Promise<HostProfile>;
renameHost: (serverId: string, label: string) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
@@ -1969,8 +1979,8 @@ export function useHostMutations(): HostMutations {
() => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
renameHost: (serverId, label) => store.renameHost(serverId, label),
removeHost: (serverId) => store.removeHost(serverId),
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),

View File

@@ -220,6 +220,7 @@ function DraftAgentScreenContent({
modeOptions,
availableModels,
allProviderModels,
allProviderEntries,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,

View File

@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
>
Back
</Button>

View File

@@ -15,14 +15,15 @@ import {
RotateCw,
Trash2,
Server,
Palette,
Keyboard,
Stethoscope,
Info,
Shield,
Puzzle,
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -40,7 +41,6 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
@@ -53,6 +53,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -61,7 +62,12 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { isCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge";
// ---------------------------------------------------------------------------
// Section definitions
@@ -69,13 +75,15 @@ import { isCompactFormFactor } from "@/constants/layout";
type SettingsSectionId =
| "hosts"
| "appearance"
| "general"
| "shortcuts"
| "integrations"
| "providers"
| "diagnostics"
| "about"
| "permissions"
| "daemon";
| "daemon"
| "pair-device";
interface SettingsSectionDef {
id: SettingsSectionId;
@@ -86,15 +94,17 @@ interface SettingsSectionDef {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "general", label: "General", icon: Settings },
{ id: "permissions", label: "Permissions", icon: Shield },
];
if (context.isDesktopApp) {
sections.push(
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
{ id: "providers", label: "Providers", icon: Blocks },
);
}
@@ -184,21 +194,14 @@ interface HostsSectionProps {
routeServerId: string;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
setPendingEditReopenServerId: (id: string | null) => void;
setIsAddHostMethodVisible: (visible: boolean) => void;
isAddHostMethodVisible: boolean;
isDirectHostVisible: boolean;
isPasteLinkVisible: boolean;
addConnectionTargetServerId: string | null;
closeAddConnectionFlow: () => void;
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
setIsPasteLinkVisible: (visible: boolean) => void;
pendingNameHost: { serverId: string; hostname: string | null } | null;
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
pendingNameHostname: string | null;
renameHost: (serverId: string, label: string) => Promise<void>;
pendingRemoveHost: HostProfile | null;
setPendingRemoveHost: (host: HostProfile | null) => void;
isRemovingHost: boolean;
@@ -210,7 +213,6 @@ interface HostsSectionProps {
handleSaveEditDaemon: (label: string) => Promise<void>;
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
handleRemoveDaemon: (profile: HostProfile) => void;
handleAddConnectionFromModal: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -250,8 +252,6 @@ function HostsSection(props: HostsSectionProps) {
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => {
props.setAddConnectionTargetServerId(null);
props.setPendingEditReopenServerId(null);
props.setIsAddHostMethodVisible(true);
}}
>
@@ -271,57 +271,27 @@ function HostsSection(props: HostsSectionProps) {
props.setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = props.addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = props.routeServerId || targetServerId || undefined;
const sourceServerId = props.routeServerId || undefined;
props.closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
params: { source: "settings", sourceServerId },
});
}}
/>
<AddHostModal
visible={props.isDirectHostVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
<PairLinkModal
visible={props.isPasteLinkVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
{props.pendingNameHost ? (
<NameHostModal
visible
serverId={props.pendingNameHost.serverId}
hostname={props.pendingNameHostname}
onSkip={() => props.setPendingNameHost(null)}
onSave={(label) => {
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
props.setPendingNameHost(null);
});
}}
/>
) : null}
{props.pendingRemoveHost ? (
<AdaptiveModalSheet
title="Remove host"
@@ -378,7 +348,6 @@ function HostsSection(props: HostsSectionProps) {
onSave={(label) => void props.handleSaveEditDaemon(label)}
onRemoveConnection={props.handleRemoveConnection}
onRemoveHost={props.handleRemoveDaemon}
onAddConnection={props.handleAddConnectionFromModal}
restartConfirmationMessage={props.restartConfirmationMessage}
waitForCondition={props.waitForCondition}
isScreenMountedRef={props.isMountedRef}
@@ -387,15 +356,20 @@ function HostsSection(props: HostsSectionProps) {
);
}
interface AppearanceSectionProps {
interface GeneralSectionProps {
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
function GeneralSection({
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
<Text style={settingsStyles.sectionTitle}>General</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
@@ -425,12 +399,146 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro
]}
/>
</View>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Default send</Text>
<Text style={styles.audioRowSubtitle}>
What happens when you press Enter while the agent is running
</Text>
</View>
<SegmentedControl
size="sm"
value={settings.sendBehavior}
onValueChange={handleSendBehaviorChange}
options={[
{ value: "interrupt", label: "Interrupt" },
{ value: "queue", label: "Queue" },
]}
/>
</View>
</View>
</View>
);
}
interface ProvidersSectionProps {
routeServerId: string;
}
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(routeServerId);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const hasServer = routeServerId.length > 0;
return (
<>
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
{hasServer && isConnected ? (
<Pressable
onPress={refresh}
disabled={isFetching}
style={[
settingsStyles.sectionHeaderLink,
isFetching ? { opacity: 0.5 } : null,
]}
>
<Text
style={{
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
}}
>
Refresh
</Text>
</Pressable>
) : null}
</View>
{!hasServer || !isConnected ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
</View>
) : isLoading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : (
<View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
const entry = entries?.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
? entry.error.trim()
: null;
return (
<View key={def.id} style={styles.audioRow}>
<View style={styles.audioRowContent}>
<View
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={styles.audioRowTitle}>{def.label}</Text>
</View>
{providerError ? (
<Text style={styles.aboutErrorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready"
? "success"
: status === "error"
? "error"
: "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
);
})}
</View>
)}
</View>
{diagnosticProvider ? (
<ProviderDiagnosticSheet
provider={diagnosticProvider}
visible
onClose={() => setDiagnosticProvider(null)}
serverId={routeServerId}
/>
) : null}
</>
);
}
interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
@@ -498,7 +606,8 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
interface SettingsSectionContentProps {
sectionId: SettingsSectionId;
hostsProps: HostsSectionProps;
appearanceProps: AppearanceSectionProps;
generalProps: GeneralSectionProps;
providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps;
appVersion: string | null;
@@ -509,7 +618,8 @@ interface SettingsSectionContentProps {
function SettingsSectionContent({
sectionId,
hostsProps,
appearanceProps,
generalProps,
providersProps,
diagnosticsProps,
aboutProps,
appVersion,
@@ -519,10 +629,12 @@ function SettingsSectionContent({
switch (sectionId) {
case "hosts":
return <HostsSection {...hostsProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "general":
return <GeneralSection {...generalProps} />;
case "shortcuts":
return <KeyboardShortcutsSection />;
case "providers":
return <ProvidersSection {...providersProps} />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
case "about":
@@ -531,6 +643,8 @@ function SettingsSectionContent({
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "pair-device":
return isDesktopApp ? <PairDeviceSection /> : null;
case "daemon":
return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
@@ -580,7 +694,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
const showSeparator =
section.id === "integrations" || section.id === "diagnostics";
section.id === "integrations" || section.id === "providers";
return (
<View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
@@ -741,14 +855,6 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
null,
);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
@@ -766,19 +872,6 @@ export default function SettingsScreen() {
const editingDaemonLive = editingServerId
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
: null;
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
return () => {
@@ -823,7 +916,6 @@ export default function SettingsScreen() {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
@@ -842,24 +934,6 @@ export default function SettingsScreen() {
handleEditDaemon(profile);
}, [daemons, handleEditDaemon, params.editHost]);
useEffect(() => {
if (!pendingEditReopenServerId) return;
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
setPendingEditReopenServerId(null);
setAddConnectionTargetServerId(null);
if (profile) {
handleEditDaemon(profile);
}
}, [
daemons,
handleEditDaemon,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
pendingEditReopenServerId,
]);
const handleSaveEditDaemon = useCallback(
async (nextLabelRaw: string) => {
if (!editingServerId) return;
@@ -897,15 +971,6 @@ export default function SettingsScreen() {
setPendingRemoveHost(profile);
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
@@ -913,6 +978,13 @@ export default function SettingsScreen() {
[updateSettings],
);
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -951,21 +1023,14 @@ export default function SettingsScreen() {
routeServerId,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
setPendingEditReopenServerId,
setIsAddHostMethodVisible,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
addConnectionTargetServerId,
closeAddConnectionFlow,
goBackToAddConnectionMethods,
setIsDirectHostVisible,
setIsPasteLinkVisible,
pendingNameHost,
setPendingNameHost,
pendingNameHostname,
renameHost,
pendingRemoveHost,
setPendingRemoveHost,
isRemovingHost,
@@ -977,15 +1042,15 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
handleAddConnectionFromModal,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
};
const appearanceProps: AppearanceSectionProps = {
const generalProps: GeneralSectionProps = {
settings,
handleThemeChange,
handleSendBehaviorChange,
};
const diagnosticsProps: DiagnosticsSectionProps = {
@@ -1000,9 +1065,14 @@ export default function SettingsScreen() {
isDesktopApp,
};
const providersProps: ProvidersSectionProps = {
routeServerId,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps,
appearanceProps,
generalProps,
providersProps,
diagnosticsProps,
aboutProps,
appVersion,
@@ -1042,7 +1112,6 @@ interface HostDetailModalProps {
onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -1060,7 +1129,6 @@ function HostDetailModal({
onSave,
onRemoveConnection,
onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
@@ -1291,15 +1359,6 @@ function HostDetailModal({
/>
);
})}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection}
>
+ Add connection
</Button>
</View>
</View>
) : null}
@@ -1821,6 +1880,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
audioRowSubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,

View File

@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
);
});
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
requestId: "req-1",
}));
const result = await closeBulkWorkspaceTabs({
await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (tabId, action) => {
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
agentIds: ["a1"],
terminalIds: ["t1", "t2"],
});
expect(result).toEqual({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
});
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
expect(closedTabIds).toEqual([
"agent_a1",
"terminal_t1",
"terminal_t2",
"file_/repo/README.md",
]);
expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
{ tabId: "file_/repo/README.md" },
]);
});
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const warn = vi.fn();
const result = await closeBulkWorkspaceTabs({
await closeBulkWorkspaceTabs({
groups,
client: {
closeItems: async () => {
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
logLabel: "others",
});
await Promise.resolve();
expect(warn).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "file_/repo/README.md" },
]);
});
});

View File

@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
otherTabs: Array<{ tabId: string }>;
};
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
return `This will archive ${agentTabs.length} agent(s).`;
}
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
}
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
[],
);
}
export async function closeBulkWorkspaceTabs(
input: CloseBulkWorkspaceTabsInput,
): Promise<CloseItemsPayload | null> {
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
let payload: CloseItemsPayload | null = null;
if (hasDestructiveTabs && client) {
try {
payload = await client.closeItems({
void client
.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
})
.catch((error) => {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
});
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
}
} else if (hasDestructiveTabs) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
error: new Error("Daemon client not available"),
});
}
const successfulAgentIds = toSuccessfulAgentIds(payload);
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
for (const { tabId, agentId } of groups.agentTabs) {
if (!successfulAgentIds.has(agentId)) {
continue;
}
await closeTab(tabId, async () => {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
}
for (const { tabId, terminalId } of groups.terminalTabs) {
if (!successfulTerminalIds.has(terminalId)) {
continue;
}
await closeTab(tabId, async () => {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId });
});
}
return payload;
}

View File

@@ -14,6 +14,7 @@ import {
ArrowRightToLine,
Columns2,
Copy,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
@@ -71,6 +72,7 @@ type WorkspaceDesktopTabsRowProps = {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
@@ -291,11 +293,14 @@ function TabChip({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
tooltip={entry.tooltip}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "rotate-cw":
return <RotateCw size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
@@ -335,6 +340,7 @@ export function WorkspaceDesktopTabsRow({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -457,6 +463,7 @@ export function WorkspaceDesktopTabsRow({
normalizedWorkspaceId={normalizedWorkspaceId}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -585,6 +592,7 @@ function ResolvedDesktopTabChip({
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -608,6 +616,7 @@ function ResolvedDesktopTabChip({
normalizedWorkspaceId: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
@@ -630,6 +639,7 @@ function ResolvedDesktopTabChip({
tabCount,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsToLeft,
onCloseTabsToRight,
@@ -644,6 +654,7 @@ function ResolvedDesktopTabChip({
onCloseTabsToRight,
onCopyAgentId,
onCopyResumeCommand,
onReloadAgent,
tabCount,
],
);

View File

@@ -75,6 +75,7 @@ export function WorkspaceDraftAgentTab({
modeOptions,
availableModels,
allProviderModels,
allProviderEntries,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
@@ -250,6 +251,60 @@ export function WorkspaceDraftAgentTab({
addImagesRef.current = addImages;
}, []);
const focusInputRef = useRef<(() => void) | null>(null);
const handleFocusInputCallback = useCallback((focus: () => void) => {
focusInputRef.current = focus;
}, []);
const handleProviderSelectWithFocus = useCallback(
(provider: string) => {
setProviderFromUser(provider);
focusInputRef.current?.();
},
[setProviderFromUser],
);
const handleModeSelectWithFocus = useCallback(
(modeId: string) => {
setModeFromUser(modeId);
focusInputRef.current?.();
},
[setModeFromUser],
);
const handleModelSelectWithFocus = useCallback(
(modelId: string) => {
setModelFromUser(modelId);
focusInputRef.current?.();
},
[setModelFromUser],
);
const handleProviderAndModelSelectWithFocus = useCallback(
(provider: string, modelId: string) => {
setProviderAndModelFromUser(provider, modelId);
focusInputRef.current?.();
},
[setProviderAndModelFromUser],
);
const handleThinkingOptionSelectWithFocus = useCallback(
(optionId: string) => {
setThinkingOptionFromUser(optionId);
focusInputRef.current?.();
},
[setThinkingOptionFromUser],
);
const handleSetFeatureWithFocus = useCallback(
(featureId: string, value: unknown) => {
setDraftFeatureValue(featureId, value);
focusInputRef.current?.();
},
[setDraftFeatureValue],
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
@@ -296,26 +351,28 @@ export function WorkspaceDraftAgentTab({
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={draftCommandConfig}
statusControls={{
providerDefinitions,
selectedProvider,
onSelectProvider: setProviderFromUser,
onSelectProvider: handleProviderSelectWithFocus,
modeOptions,
selectedMode,
onSelectMode: setModeFromUser,
onSelectMode: handleModeSelectWithFocus,
models: availableModels,
selectedModel,
onSelectModel: setModelFromUser,
onSelectModel: handleModelSelectWithFocus,
isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel: setProviderAndModelFromUser,
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: () => focusInputRef.current?.(),
disabled: isSubmitting,
}}
/>

View File

@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
} from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
resolvePreferredEditorId,
usePreferredEditor,
} from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
}: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors =
Platform.OS === "web" &&
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadEditors,
staleTime: 60_000,
retry: false,
queryFn: async () => {
if (!client) {
return [];
}
try {
const payload = await client.listAvailableEditors();
return payload.error ? [] : payload.editors;
} catch {
return [];
}
},
});
const availableEditors = availableEditorsQuery.data ?? [];
const availableEditorIds = useMemo(
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
[availableEditors],
);
const effectivePreferredEditorId = useMemo(
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
[availableEditorIds, preferredEditorId],
);
const primaryOption =
availableEditors.find(
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
) ?? null;
useEffect(() => {
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
return;
}
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
const openMutation = useMutation({
mutationFn: async (editorId: EditorTargetId) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.openInEditor(cwd, editorId);
if (payload.error) {
throw new Error(payload.error);
}
return editorId;
},
onError: (error: unknown) => {
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
},
});
const handleOpenEditor = useCallback(
(editorId: EditorTargetId) => {
void updatePreferredEditor(editorId).catch(() => undefined);
openMutation.mutate(editorId);
},
[openMutation, updatePreferredEditor],
);
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
return null;
}
return (
<View style={styles.row}>
<View style={styles.splitButton}>
<Pressable
testID="workspace-open-in-editor-primary"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
]}
onPress={() => handleOpenEditor(primaryOption.id)}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
>
{openMutation.isPending ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
<EditorAppIcon
editorId={primaryOption.id}
size={16}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.splitButtonText}>Open</Text>
</View>
)}
</Pressable>
{availableEditors.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-open-in-editor-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
accessibilityRole="button"
accessibilityLabel="Choose editor"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
minWidth={148}
maxWidth={176}
testID="workspace-open-in-editor-menu"
>
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
<DropdownMenuItem
key={editor.id}
testID={`workspace-open-in-editor-item-${editor.id}`}
leading={
<EditorAppIcon
editorId={editor.id}
size={16}
color={theme.colors.foregroundMuted}
/>
}
trailing={
editor.id === effectivePreferredEditorId
? <Check size={16} color={theme.colors.foregroundMuted} />
: undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
{editor.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingLeft: theme.spacing[3],
paddingRight: 10,
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -21,6 +21,7 @@ import {
Ellipsis,
EllipsisVertical,
PanelRight,
RotateCw,
SquarePen,
SquareTerminal,
X,
@@ -47,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -77,7 +79,7 @@ import {
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -182,6 +184,7 @@ type MobileWorkspaceTabSwitcherProps = {
onSelectSwitcherTab: (key: string) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
@@ -269,6 +272,7 @@ function MobileWorkspaceTabOption({
onPress,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
@@ -284,6 +288,7 @@ function MobileWorkspaceTabOption({
onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
@@ -299,6 +304,7 @@ function MobileWorkspaceTabOption({
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
@@ -342,11 +348,14 @@ function MobileWorkspaceTabOption({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
tooltip={entry.tooltip}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "rotate-cw":
return <RotateCw size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
@@ -389,6 +398,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onSelectSwitcherTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
@@ -460,6 +470,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
@@ -1203,10 +1214,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
await killTerminalAsync(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
@@ -1216,13 +1223,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
});
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}
void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
});
},
[
@@ -1243,18 +1255,22 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
const confirmed = await confirmDialog({
title: "Archive agent?",
message: "This closes the tab and archives the agent.",
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
if (agent?.status !== "idle") {
const confirmed = await confirmDialog({
title: "Archive agent?",
message: "This closes the tab and archives the agent.",
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
}
await archiveAgent({ serverId: normalizedServerId, agentId });
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
@@ -1263,6 +1279,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
target: { kind: "agent", agentId },
});
}
void archiveAgent({ serverId: normalizedServerId, agentId });
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
@@ -1298,29 +1316,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
);
const prevCloseTabDeps = useRef({
allTabDescriptorsById,
handleCloseAgentTab,
handleCloseDraftOrFileTab,
handleCloseTerminalTab,
});
useEffect(() => {
const prev = prevCloseTabDeps.current;
const changed: string[] = [];
if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById");
if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab");
if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab)
changed.push("handleCloseDraftOrFileTab");
if (prev.handleCloseTerminalTab !== handleCloseTerminalTab)
changed.push("handleCloseTerminalTab");
if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", "));
prevCloseTabDeps.current = {
allTabDescriptorsById,
handleCloseAgentTab,
handleCloseDraftOrFileTab,
handleCloseTerminalTab,
};
});
const handleCopyAgentId = useCallback(
async (agentId: string) => {
if (!agentId) return;
@@ -1366,6 +1361,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[normalizedServerId, toast],
);
const handleReloadAgent = useCallback(
async (agentId: string) => {
if (!client || !isConnected) {
toast.error("Host is not connected");
return;
}
try {
await client.refreshAgent(agentId);
toast.show("Reloaded agent", { variant: "success" });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to reload agent");
}
},
[client, isConnected, toast],
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!isAbsolutePath(normalizedWorkspaceId)) {
toast.error("Workspace path not available");
@@ -1413,7 +1425,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
const closeItemsPayload = await closeBulkWorkspaceTabs({
await closeBulkWorkspaceTabs({
client,
groups,
closeTab,
@@ -1429,31 +1441,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
},
});
if (closeItemsPayload) {
for (const terminal of closeItemsPayload.terminals) {
if (!terminal.success) {
continue;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
};
});
}
if (normalizedServerId) {
applyArchivedAgentCloseResults({
queryClient,
serverId: normalizedServerId,
results: closeItemsPayload.agents,
});
}
}
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
@@ -1462,10 +1449,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
client,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
persistenceKey,
queryClient,
terminalsQueryKey,
],
);
@@ -1768,44 +1752,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
retargetWorkspaceTab,
],
);
const prevBuildDeps = useRef({
handleCloseTabById,
handleOpenFileFromChat,
focusWorkspacePane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
openWorkspaceTab,
persistenceKey,
retargetWorkspaceTab,
});
useEffect(() => {
const prev = prevBuildDeps.current;
const changed: string[] = [];
if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById");
if (prev.handleOpenFileFromChat !== handleOpenFileFromChat)
changed.push("handleOpenFileFromChat");
if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane");
if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId");
if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId");
if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId");
if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab");
if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey");
if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab");
if (changed.length > 0)
console.log("[buildPaneContentModel] deps changed:", changed.join(", "));
prevBuildDeps.current = {
handleCloseTabById,
handleOpenFileFromChat,
focusWorkspacePane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
openWorkspaceTab,
persistenceKey,
retargetWorkspaceTab,
};
});
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
@@ -2079,6 +2025,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}
right={
<View style={styles.headerRight}>
{!isMobile ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
@@ -2211,6 +2163,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
@@ -2231,6 +2184,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
@@ -2267,6 +2221,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}

View File

@@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
it("uses desktop tab ordering labels for desktop menus", () => {
const onCopyResumeCommand = vi.fn();
const onCopyAgentId = vi.fn();
const onReloadAgent = vi.fn();
const onCloseTab = vi.fn();
const onCloseTabsBefore = vi.fn();
const onCloseTabsAfter = vi.fn();
@@ -28,6 +29,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -40,6 +42,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
"Close to the left",
"Close to the right",
"Close other tabs",
"Reload agent",
"Close",
]);
});
@@ -53,6 +56,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -65,6 +69,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
"Close tabs above",
"Close tabs below",
"Close other tabs",
"Reload agent",
"Close",
]);
});
@@ -83,6 +88,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-draft_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -92,6 +98,34 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
});
it("adds reload tooltip copy for agent tabs", () => {
const entries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab: createAgentTab(),
index: 0,
tabCount: 1,
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
expect(entries).toContainEqual(
expect.objectContaining({
kind: "item",
key: "reload-agent",
tooltip: "Reload agent to update skills, MCPs or login status.",
}),
);
});
});

View File

@@ -8,8 +8,15 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
icon?:
| "copy"
| "rotate-cw"
| "arrow-left-to-line"
| "arrow-right-to-line"
| "copy-x"
| "x";
hint?: string;
tooltip?: string;
disabled?: boolean;
destructive?: boolean;
testID: string;
@@ -28,6 +35,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
menuTestIDBase: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
@@ -40,6 +48,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
tabCount: number;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
@@ -92,6 +101,7 @@ export function buildWorkspaceTabMenuEntries(
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -164,6 +174,20 @@ export function buildWorkspaceTabMenuEntries(
void onCloseOtherTabs(tab.tabId);
},
});
if (tab.target.kind === "agent") {
const { agentId } = tab.target;
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
},
});
}
entries.push({
kind: "item",
key: "close",
@@ -192,6 +216,7 @@ export function buildWorkspaceDesktopTabActions(
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId,
onReloadAgent: input.onReloadAgent,
onCloseTab: input.onCloseTab,
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,

View File

@@ -66,6 +66,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
sidebarWidth: number;
explorerWidth: number;
@@ -85,6 +87,8 @@ interface PanelState {
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setSidebarWidth: (width: number) => void;
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
activeExplorerCheckout: null,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
}
return nextState;
}),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 8,
version: 10,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
state.activeExplorerCheckout = null;
return state as PanelState;
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
mergeWorkspaceSnapshotWithExisting,
type WorkspaceDescriptor,
} from "./session-store";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "remote:github.com/getpaseo/paseo",
projectDisplayName: input.projectDisplayName ?? "getpaseo/paseo",
projectRootPath: input.projectRootPath ?? "/tmp/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
};
}
describe("mergeWorkspaceSnapshotWithExisting", () => {
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
const existing = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 4, deletions: 2 },
});
const incoming = createWorkspace({
id: "/tmp/repo",
diffStat: null,
});
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
...incoming,
diffStat: { additions: 4, deletions: 2 },
});
});
it("uses the incoming diff stat when the server provides a known value", () => {
const existing = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 4, deletions: 2 },
});
const incoming = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 0, deletions: 0 },
});
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
});
});

View File

@@ -21,6 +21,7 @@ import type {
import type {
FileDownloadTokenResponse,
GitSetupOptions,
ServerInfoStatusPayload,
ProjectPlacementPayload,
ServerCapabilities,
WorkspaceDescriptorPayload,
@@ -141,6 +142,21 @@ export function normalizeWorkspaceDescriptor(
};
}
export function mergeWorkspaceSnapshotWithExisting(input: {
incoming: WorkspaceDescriptor;
existing?: WorkspaceDescriptor | null;
}): WorkspaceDescriptor {
const { incoming, existing } = input;
if (!existing || existing.id !== incoming.id) {
return incoming;
}
return {
...incoming,
diffStat: incoming.diffStat ?? existing.diffStat,
};
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
@@ -190,6 +206,7 @@ export type DaemonServerInfo = {
hostname: string | null;
version: string | null;
capabilities?: ServerCapabilities;
features?: ServerInfoStatusPayload["features"];
};
export interface AgentTimelineCursorState {
@@ -412,6 +429,13 @@ function areServerCapabilitiesEqual(
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
function areServerInfoFeaturesEqual(
current: ServerInfoStatusPayload["features"] | undefined,
next: ServerInfoStatusPayload["features"] | undefined,
): boolean {
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
export const useSessionStore = create<SessionStore>()(
subscribeWithSelector((set, get) => {
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
@@ -527,12 +551,15 @@ export const useSessionStore = create<SessionStore>()(
const prevVersion = session.serverInfo?.version?.trim() || null;
const nextCapabilities = info.capabilities;
const prevCapabilities = session.serverInfo?.capabilities;
const nextFeatures = info.features;
const prevFeatures = session.serverInfo?.features;
if (
session.serverInfo?.serverId === info.serverId &&
prevHostname === nextHostname &&
prevVersion === nextVersion &&
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) &&
areServerInfoFeaturesEqual(prevFeatures, nextFeatures)
) {
return prev;
}
@@ -548,6 +575,7 @@ export const useSessionStore = create<SessionStore>()(
hostname: nextHostname,
version: nextVersion,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
...(nextFeatures ? { features: nextFeatures } : {}),
},
},
},

View File

@@ -111,6 +111,7 @@ const lightSemanticColors = {
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceWorkspace: "#ffffff", // Workspace main background
@@ -185,6 +186,7 @@ const darkSemanticColors = {
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
@@ -279,6 +281,10 @@ const commonTheme = {
"4xl": 34,
},
lineHeight: {
diff: 22,
},
iconSize: {
xs: 12,
sm: 14,

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildSplitDiffRows } from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
return {
path: "example.ts",
isNew: false,
isDeleted: false,
additions: lines.filter((line) => line.type === "add").length,
deletions: lines.filter((line) => line.type === "remove").length,
status: "ok",
hunks: [
{
oldStart: 10,
oldCount: 4,
newStart: 10,
newCount: 5,
lines,
},
],
};
}
describe("buildSplitDiffRows", () => {
it("pairs replacement runs by index", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,2 +10,2 @@" },
{ type: "remove", content: "before one" },
{ type: "remove", content: "before two" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows).toHaveLength(3);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before one", lineNumber: 10 },
right: { type: "add", content: "after one", lineNumber: 10 },
});
expect(rows[2]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before two", lineNumber: 11 },
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("keeps unmatched additions on the right side only", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,2 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows[2]).toMatchObject({
kind: "pair",
left: null,
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("duplicates context rows on both sides", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,1 @@" },
{ type: "context", content: "same line" },
]),
);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "context", content: "same line", lineNumber: 10 },
right: { type: "context", content: "same line", lineNumber: 10 },
});
});
});

View File

@@ -0,0 +1,147 @@
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
export interface SplitDiffDisplayLine {
type: DiffLine["type"];
content: string;
tokens?: DiffLine["tokens"];
lineNumber: number | null;
}
export type SplitDiffRow =
| {
kind: "header";
content: string;
}
| {
kind: "pair";
left: SplitDiffDisplayLine | null;
right: SplitDiffDisplayLine | null;
};
function toDisplayLine(input: {
line: DiffLine;
oldLineNumber: number | null;
newLineNumber: number | null;
side: "left" | "right";
}): SplitDiffDisplayLine | null {
const { line, oldLineNumber, newLineNumber, side } = input;
if (line.type === "header") {
return null;
}
if (line.type === "remove") {
if (side !== "left") {
return null;
}
return {
type: "remove",
content: line.content,
tokens: line.tokens,
lineNumber: oldLineNumber,
};
}
if (line.type === "add") {
if (side !== "right") {
return null;
}
return {
type: "add",
content: line.content,
tokens: line.tokens,
lineNumber: newLineNumber,
};
}
return {
type: "context",
content: line.content,
tokens: line.tokens,
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
};
}
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
for (const hunk of file.hunks) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
rows.push({
kind: "header",
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
});
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
const flushPendingRows = () => {
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
for (let index = 0; index < pairCount; index += 1) {
const removal = pendingRemovals[index] ?? null;
const addition = pendingAdditions[index] ?? null;
rows.push({
kind: "pair",
left: removal
? toDisplayLine({
line: removal.line,
oldLineNumber: removal.oldLineNumber,
newLineNumber: null,
side: "left",
})
: null,
right: addition
? toDisplayLine({
line: addition.line,
oldLineNumber: null,
newLineNumber: addition.newLineNumber,
side: "right",
})
: null,
});
}
pendingRemovals = [];
pendingAdditions = [];
};
for (const line of hunk.lines.slice(1)) {
if (line.type === "remove") {
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
oldLineNo += 1;
continue;
}
if (line.type === "add") {
pendingAdditions.push({ line, newLineNumber: newLineNo });
newLineNo += 1;
continue;
}
flushPendingRows();
if (line.type === "context") {
rows.push({
kind: "pair",
left: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "left",
}),
right: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "right",
}),
});
oldLineNo += 1;
newLineNo += 1;
}
}
flushPendingRows();
}
return rows;
}

View File

@@ -268,99 +268,99 @@ export function parseHostWorkspaceRouteFromPathname(
return { serverId, workspaceId };
}
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
const normalizedServerId = trimNonEmpty(serverId);
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (!normalizedServerId || !normalizedWorkspaceId) {
return "/";
return "/" as const;
}
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
if (!encodedWorkspaceId) {
return "/";
return "/" as const;
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const;
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string,
workspaceId?: string,
): string {
) {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedAgentId) {
return "/";
return "/" as const;
}
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
if (base === "/") {
return "/";
return "/" as const;
}
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}` as const;
}
const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedServerId || !normalizedAgentId) {
return "/";
return "/" as const;
}
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`;
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
}
export function buildHostRootRoute(serverId: string): string {
export function buildHostRootRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
return "/";
return "/" as const;
}
return `/h/${encodeSegment(normalized)}`;
return `/h/${encodeSegment(normalized)}` as const;
}
export function buildHostSessionsRoute(serverId: string): string {
export function buildHostSessionsRoute(serverId: string) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
return "/";
return "/" as const;
}
return `${base}/sessions`;
return `${base}/sessions` as const;
}
export function buildHostOpenProjectRoute(serverId: string): string {
export function buildHostOpenProjectRoute(serverId: string) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
return "/";
return "/" as const;
}
return `${base}/open-project`;
return `${base}/open-project` as const;
}
export function buildHostSettingsRoute(serverId: string): string {
export function buildHostSettingsRoute(serverId: string) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
return "/";
return "/" as const;
}
return `${base}/settings`;
return `${base}/settings` as const;
}
export function mapPathnameToServer(pathname: string, nextServerId: string): string {
export function mapPathnameToServer(pathname: string, nextServerId: string) {
const normalized = trimNonEmpty(nextServerId);
if (!normalized) {
return "/";
return "/" as const;
}
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
const base = buildHostRootRoute(normalized);
if (suffix.startsWith("settings")) {
return `${base}/settings`;
return `${base}/settings` as const;
}
if (suffix.startsWith("sessions")) {
return `${base}/sessions`;
return `${base}/sessions` as const;
}
if (suffix.startsWith("open-project")) {
return `${base}/open-project`;
return `${base}/open-project` as const;
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) {
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
}
if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`;
return `${base}/${suffix}` as const;
}
return base;
}

View File

@@ -69,7 +69,7 @@ export function resolveNewAgentWorkingDir(
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
}
export function buildNewAgentRoute(serverId: string, workingDir?: string | null): string {
export function buildNewAgentRoute(serverId: string, workingDir?: string | null) {
const trimmedWorkingDir = workingDir?.trim();
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
}

View File

@@ -27,17 +27,17 @@ export function resolveNotificationTarget(data: NotificationData): {
};
}
export function buildNotificationRoute(data: NotificationData): string {
export function buildNotificationRoute(data: NotificationData) {
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
if (serverId && agentId) {
if (workspaceId) {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`;
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
}
return buildHostAgentDetailRoute(serverId, agentId);
}
if (serverId) {
return buildHostRootRoute(serverId);
}
return "/";
return "/" as const;
}

View File

@@ -2,6 +2,7 @@ import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
@@ -53,6 +54,7 @@ export async function buildClientConfig(
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...(connection.type === "directSocket" || connection.type === "directPipe"

View File

@@ -52,7 +52,7 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string;
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string {
}) {
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: input.archivedWorkspaceId,
workspaces: input.workspaces,

View File

@@ -20,7 +20,7 @@ function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
return { kind: "draft", draftId: generateDraftId() };
}
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
const target = getPreparedTarget(input.target);
const key =
buildWorkspaceTabPersistenceKey({

View File

@@ -14,20 +14,6 @@ interface AudioEngineTraceOptions {
traceLabel?: string;
}
let nextAudioEngineInstanceId = 1;
interface BridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
volumeEvents: number;
volumeMax: number;
playbackEvents: number;
playbackInputBytes: number;
playbackResampledBytes: number;
playbackDurationMs: number;
}
function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
@@ -85,48 +71,6 @@ export function createAudioEngine(
_options?: AudioEngineTraceOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
const instanceId = nextAudioEngineInstanceId++;
const bridgeStats: BridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
volumeEvents: 0,
volumeMax: 0,
playbackEvents: 0,
playbackInputBytes: 0,
playbackResampledBytes: 0,
playbackDurationMs: 0,
};
const toHexPreview = (bytes: Uint8Array, count = 12): string =>
Array.from(bytes.slice(0, count))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ");
const maybeFlushBridgeStats = (reason: string): void => {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` +
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
`windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.volumeEvents = 0;
bridgeStats.volumeMax = 0;
bridgeStats.playbackEvents = 0;
bridgeStats.playbackInputBytes = 0;
bridgeStats.playbackResampledBytes = 0;
bridgeStats.playbackDurationMs = 0;
};
const refs: {
initialized: boolean;
@@ -140,8 +84,6 @@ export function createAudioEngine(
reject: (error: Error) => void;
settled: boolean;
} | null;
sawFirstMicChunk: boolean;
sawFirstVolumeEvent: boolean;
destroyed: boolean;
} = {
initialized: false,
@@ -151,8 +93,6 @@ export function createAudioEngine(
processingQueue: false,
playbackTimeout: null,
activePlayback: null,
sawFirstMicChunk: false,
sawFirstVolumeEvent: false,
destroyed: false,
};
@@ -163,15 +103,6 @@ export function createAudioEngine(
return;
}
const pcm = event.data as Uint8Array;
if (!refs.sawFirstMicChunk) {
refs.sawFirstMicChunk = true;
console.log(
`[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`,
);
}
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += pcm.byteLength;
maybeFlushBridgeStats("capture");
callbacks.onCaptureData(pcm);
},
);
@@ -182,26 +113,10 @@ export function createAudioEngine(
return;
}
const level = refs.muted ? 0 : event.data;
bridgeStats.volumeEvents += 1;
bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level);
if (!refs.sawFirstVolumeEvent) {
refs.sawFirstVolumeEvent = true;
console.log(
`[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`,
);
}
maybeFlushBridgeStats("volume");
callbacks.onVolumeLevel(level);
},
);
const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener(
"onOutputVolumeLevelData",
(event: any) => {
console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`);
},
);
async function ensureInitialized(): Promise<void> {
if (refs.initialized) {
return;
@@ -210,20 +125,13 @@ export function createAudioEngine(
if (!success) {
throw new Error("expo-two-way-audio: native initialize() returned false");
}
console.log(`[AudioEngine.native#${instanceId}] initialized successfully`);
refs.initialized = true;
}
async function ensureMicrophonePermission(): Promise<void> {
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
if (!permission?.granted) {
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
}
if (!permission?.granted) {
throw new Error(
@@ -253,17 +161,6 @@ export function createAudioEngine(
// Native AudioEngine expects 16kHz PCM16
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
const durationSec = pcm16k.length / 2 / 16000;
bridgeStats.playbackEvents += 1;
bridgeStats.playbackInputBytes += pcm.length;
bridgeStats.playbackResampledBytes += pcm16k.length;
bridgeStats.playbackDurationMs += durationSec * 1000;
console.log(
`[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` +
`resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` +
`pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`,
);
maybeFlushBridgeStats("play");
native.resumePlayback();
native.playPCMData(pcm16k);
@@ -334,26 +231,18 @@ export function createAudioEngine(
}
microphoneSubscription.remove();
volumeSubscription.remove();
outputVolumeSubscription.remove();
},
async startCapture() {
if (refs.captureActive) {
console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`);
return;
}
try {
console.log(`[AudioEngine.native#${instanceId}] startCapture begin`);
await ensureMicrophonePermission();
await ensureInitialized();
refs.sawFirstMicChunk = false;
refs.sawFirstVolumeEvent = false;
const isRecording = native.toggleRecording(true);
native.toggleRecording(true);
refs.captureActive = true;
console.log(
`[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`,
);
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
callbacks.onError?.(wrapped);
@@ -363,10 +252,7 @@ export function createAudioEngine(
async stopCapture() {
if (refs.captureActive) {
const isRecording = native.toggleRecording(false);
console.log(
`[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`,
);
native.toggleRecording(false);
}
refs.captureActive = false;
refs.muted = false;

View File

@@ -3,7 +3,6 @@ import type { AgentStreamEventPayload, SessionOutboundMessage } from "@server/sh
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
import {
THINKING_TONE_NATIVE_PCM_BASE64,
THINKING_TONE_NATIVE_PCM_DURATION_MS,
@@ -82,8 +81,6 @@ interface RuntimeState {
segmentDurationTimer: ReturnType<typeof setInterval> | null;
lastDisplayVolumePublishMs: number;
serverSpeechStartedAt: number | null;
lastNoServerSpeechLogMs: number;
localAboveThresholdActive: boolean;
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
@@ -120,19 +117,6 @@ interface CueState {
playing: boolean;
}
interface RealtimeBridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
uplinkEvents: number;
uplinkRawBytes: number;
uplinkBase64Chars: number;
outputEvents: number;
outputBytes: number;
outputGroups: number;
jsLagMaxMs: number;
}
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
@@ -210,8 +194,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
segmentDurationTimer: null,
lastDisplayVolumePublishMs: 0,
serverSpeechStartedAt: null,
lastNoServerSpeechLogMs: 0,
localAboveThresholdActive: false,
};
const playback: RuntimePlaybackState = {
groups: new Map(),
@@ -220,18 +202,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
processing: false,
generation: 0,
};
const bridgeStats: RealtimeBridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
uplinkEvents: 0,
uplinkRawBytes: 0,
uplinkBase64Chars: 0,
outputEvents: 0,
outputBytes: 0,
outputGroups: 0,
jsLagMaxMs: 0,
};
const cue: CueState = {
active: false,
token: 0,
@@ -246,41 +216,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return cuePcm16.buffer.slice(cuePcm16.byteOffset, cuePcm16.byteOffset + cuePcm16.byteLength);
},
};
let lagProbeLastMs = Date.now();
const lagProbe = setInterval(() => {
const now = Date.now();
const lagMs = Math.max(0, now - lagProbeLastMs - 100);
lagProbeLastMs = now;
if (lagMs > bridgeStats.jsLagMaxMs) {
bridgeStats.jsLagMaxMs = lagMs;
}
}, 100);
function flushBridgeStats(reason: string): void {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[VoiceRuntime#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` +
`output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` +
`jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.uplinkEvents = 0;
bridgeStats.uplinkRawBytes = 0;
bridgeStats.uplinkBase64Chars = 0;
bridgeStats.outputEvents = 0;
bridgeStats.outputBytes = 0;
bridgeStats.outputGroups = 0;
bridgeStats.jsLagMaxMs = 0;
}
function emit(): void {
for (const listener of listeners) {
listener();
@@ -354,17 +289,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
function resetPlaybackState(): void {
const hadGroups = playback.groups.size;
playback.generation += 1;
playback.groups.clear();
playback.orderedGroupIds = [];
playback.activeGroupId = null;
playback.processing = false;
if (hadGroups > 0) {
console.log(
`[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`,
);
}
}
function activateNextPlaybackGroup(): void {
@@ -394,15 +323,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
playback.processing = true;
const generation = playback.generation;
console.log(
`[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`,
);
try {
while (playback.activeGroupId) {
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -415,9 +338,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const nextChunk = group.chunks.get(group.nextChunkToPlay);
if (!nextChunk) {
if (group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex) {
console.log(
`[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`,
);
playback.groups.delete(group.groupId);
if (playback.orderedGroupIds[0] === group.groupId) {
playback.orderedGroupIds.shift();
@@ -432,9 +352,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
activateNextPlaybackGroup();
continue;
}
console.log(
`[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`,
);
return;
}
@@ -442,39 +359,21 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (group.shouldPlay && !group.started && group.isVoiceMode) {
group.started = true;
console.log(
`[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`,
);
api.onAssistantAudioStarted(serverId);
}
const playStart = Date.now();
try {
if (group.shouldPlay) {
await deps.engine.play(nextChunk.source);
console.log(
`[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`,
);
} else {
console.log(
`[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`,
);
}
} catch (error) {
if (generation !== playback.generation) {
console.log(`[VoiceRuntime] play error + generation changed, aborting`);
return;
}
console.error(
`[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`,
error,
);
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay}:`, error);
}
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -491,9 +390,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (generation === playback.generation) {
playback.processing = false;
}
console.log(
`[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`,
);
}
}
@@ -606,12 +502,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
const base64 = Buffer.from(chunk).toString("base64");
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += chunk.byteLength;
bridgeStats.uplinkEvents += 1;
bridgeStats.uplinkRawBytes += chunk.byteLength;
bridgeStats.uplinkBase64Chars += base64.length;
flushBridgeStats("uplink");
void activeSession.adapter.sendVoiceAudioChunk(base64, PCM_MIME_TYPE).catch((error) => {
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
@@ -624,8 +514,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
state.turnInProgress = false;
state.serverSpeechDetected = false;
state.lastDisplayVolumePublishMs = 0;
state.lastNoServerSpeechLogMs = 0;
state.localAboveThresholdActive = false;
uploader.reset();
resetCaptureTelemetry();
patchSnapshot({ ...INITIAL_SNAPSHOT });
@@ -746,11 +634,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
return;
}
if (bridgeStats.captureEvents === 0) {
console.log(
`[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
uploader.pushPcmChunk(chunk);
},
@@ -767,30 +650,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
if (isActive && !state.localAboveThresholdActive) {
state.localAboveThresholdActive = true;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
if (!isActive && state.localAboveThresholdActive) {
state.localAboveThresholdActive = false;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`,
);
}
if (
isActive &&
!state.serverSpeechDetected &&
nowMs - state.lastNoServerSpeechLogMs >= 1500
) {
state.lastNoServerSpeechLogMs = nowMs;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`,
);
}
patchTelemetry((prev) => ({
...prev,
isSpeaking: state.serverSpeechDetected,
@@ -805,33 +664,15 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
!state.snapshot.isVoiceMode ||
!payload.isVoiceMode
) {
console.log(
`[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`,
);
return;
}
const groupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const decoded = decodeAudioChunk(payload.audio);
bridgeStats.outputEvents += 1;
bridgeStats.outputBytes += decoded.byteLength;
bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1;
console.log(
`[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` +
`base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` +
`head=${Array.from(decoded.slice(0, 12))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ")}`,
);
flushBridgeStats("audio_output");
let group = playback.groups.get(groupId);
if (!group) {
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
console.log(
`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`,
);
group = {
groupId,
isVoiceMode: payload.isVoiceMode,
@@ -965,7 +806,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
async destroy() {
await this.stopVoice().catch(() => undefined);
clearInterval(lagProbe);
await deps.engine.destroy();
listeners.clear();
telemetryListeners.clear();
@@ -1054,9 +894,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
console.log(
`[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`,
);
state.serverSpeechDetected = isSpeaking;
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
if (isSpeaking) {

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