Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 58d1767a64 Fix mobile sidebar reset after theme switch 2026-03-28 09:59:50 +07:00
Mohamed Boudra 0ab346e5a2 fix(desktop): restore windows daemon bootstrap logging 2026-03-27 22:48:27 +07:00
Mohamed Boudra a8c04c26b9 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-27 21:40:02 +07:00
Mohamed Boudra e2b2b7c701 chore(release): cut 0.1.37 2026-03-27 21:11:53 +07:00
Mohamed Boudra af35cccb5f fix(release): skip release notes sync when no changelog entry exists
Draft releases don't have changelog entries, so the script should log
and continue rather than throwing.
2026-03-27 21:10:03 +07:00
github-actions[bot] 6342057a6f fix: update lockfile signatures and Nix hash 2026-03-27 14:00:20 +00:00
Mohamed Boudra 89923b5f76 fix(ci): default to draft release when no GitHub release exists
The fallback was "release", so pushing a tag without pre-creating a
draft release on GitHub caused electron-builder to create a published
release instead of a draft.
2026-03-27 20:59:07 +07:00
Mohamed Boudra 54b5a22688 fix(windows): broken PATH propagation, ps usage, and claude binary resolution
- sherpa-runtime-env: use case-insensitive key lookup when modifying PATH
  on plain env objects. On Windows, `{...process.env}` stores PATH as
  `Path` but `applySherpaLoaderEnv` used hardcoded `"PATH"`, creating a
  duplicate key that could shadow the real system PATH in child processes.

- runtime-toolchain: use `wmic` on Windows instead of Unix-only `ps -o`
  to resolve the node executable path from a PID.

- claude-agent: pass `findExecutable("claude")` as
  `pathToClaudeCodeExecutable` so the SDK uses the user's installed
  binary when available. Update spawn hook to only replace bare
  "node"/"bun" with process.execPath, preserving native binary paths.

- desktop/paseo.cmd: use ELECTRON_RUN_AS_NODE with node-entrypoint-runner
  for the Windows CLI wrapper.
2026-03-27 20:50:18 +07:00
Mohamed Boudra ab3443fd52 feat(desktop): hide native titlebar on Windows/Linux with overlay window controls
Use titleBarStyle: 'hidden' on all platforms. On Windows/Linux, enable
titleBarOverlay with dark theme colors for native min/max/close buttons.
Extend useTrafficLightPadding to return side ('left'|'right'|null) so
consumers apply padding on the correct side per platform.
2026-03-27 19:59:17 +07:00
Mohamed Boudra 95ad879f82 fix: show toast on dictation errors instead of silent console log 2026-03-27 19:27:29 +07:00
Mohamed Boudra 80e153f861 feat(desktop): add file logging with electron-log
Logs from both main and renderer processes now persist to
~/Library/Logs/Paseo/main.log (macOS) with automatic rotation.
Removes the unused webview_log IPC handler.
2026-03-27 19:22:40 +07:00
Mohamed Boudra dfa376f5ca docs: add 0.1.36 changelog entry 2026-03-27 18:48:06 +07:00
Mohamed Boudra 76c357c88c fix: run release:check before version bump to prevent dirty state on failure 2026-03-27 18:44:18 +07:00
github-actions[bot] 5adde123e3 fix: update lockfile signatures and Nix hash 2026-03-27 11:32:43 +00:00
Mohamed Boudra 57291b1fd9 chore(release): cut 0.1.36 2026-03-27 18:30:49 +07:00
Mohamed Boudra 89f285ffd0 docs: clarify draft release flow and changelog dependency 2026-03-27 18:29:53 +07:00
Mohamed Boudra e6c06e2263 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-27 18:18:56 +07:00
Mohamed Boudra ca443b3ecf fix: handle Windows drive-letter paths across the codebase (#148)
* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
2026-03-27 18:17:48 +07:00
Mohamed Boudra d8e9298222 Add metrics collection and terminal performance tests 2026-03-26 23:38:32 +07:00
Mohamed Boudra c667aca3e0 chore: remove stale agent-event-stream-redesign doc 2026-03-26 20:56:03 +07:00
Mohamed Boudra 7892b3cbe1 fix(nix): update stale npmDepsHash and auto-fix on all lockfile changes
Hash mismatch was hidden by continue-on-error. Now the Nix Build
workflow fails visibly, and fix-nix-hash runs on every push/PR that
touches the lockfile (not just Dependabot).
2026-03-26 20:19:12 +07:00
Mohamed Boudra f2000ff789 chore(release): cut 0.1.35 2026-03-26 18:14:00 +07:00
Mohamed Boudra 759815cfac docs: add v0.1.35 changelog 2026-03-26 18:13:33 +07:00
Mohamed Boudra 68df304867 refactor(app): move startup routing logic into welcome screen and simplify index route 2026-03-26 18:10:21 +07:00
Mohamed Boudra a2aac3797f fix(server): translate Codex file deletions as removed lines instead of added lines
Codex delete operations were displayed as edits with green (added) lines
because the translation pipeline didn't distinguish deletes from edits.
Now detects kind="delete" and *** Delete File directives, producing proper
unified diffs with removed lines and +++ /dev/null headers.
2026-03-26 17:53:57 +07:00
Mohamed Boudra f13178a9ad Replace mapfile with portable while-read loop in chat script
mapfile is a bash 4+ builtin and fails on macOS default bash 3.2
with "command not found".
2026-03-26 17:51:56 +07:00
Mohamed Boudra c1d71dfedc Fix queued prompt dispatch after idle transition 2026-03-26 15:14:05 +07:00
Mohamed Boudra 2e7bc49c4c Make mobile mockup horizontally scrollable on small screens
Prevents the 4-phone composite image from shrinking to illegibility
on mobile by setting a min-width and enabling horizontal scroll.
2026-03-26 15:12:53 +07:00
Mohamed Boudra 55dd3a9b1a Add Homebrew Cask install to downloads page, fix APK URL
- Extract CommandDialog shared component from landing page's ServerInstallButton
- Add Homebrew pill to macOS section on download page that opens install dialog
- Add ESC key support to close the command dialog
- Fix APK download URL (paseo-v${version}-android.apk)
2026-03-26 15:08:16 +07:00
Mohamed Boudra 5b3aea8bfe fix(server): surface opencode questions in permission UI 2026-03-26 14:49:52 +07:00
Mohamed Boudra e47eb64de0 Add privacy-first to README, tweak sponsor copy 2026-03-26 11:49:10 +07:00
Mohamed Boudra eada5eec53 Update landing page: mobile section, sponsor copy, muted color consistency 2026-03-26 11:46:16 +07:00
Mohamed Boudra 1ee7cace36 Update orchestration skills description 2026-03-26 11:20:41 +07:00
Mohamed Boudra 612731a5b5 Add syntax highlighting to skill examples 2026-03-26 11:18:36 +07:00
Mohamed Boudra f50252c3b1 Add descriptions to skill examples in README 2026-03-26 11:17:54 +07:00
Mohamed Boudra 3cd1552ddc Add CLI and orchestration skills sections to README 2026-03-26 11:13:59 +07:00
Mohamed Boudra caab3f9686 Update subtitle and mobile mockup 2026-03-26 11:05:53 +07:00
Mohamed Boudra bf8ba4da8f Update mockups and add mobile section to landing page 2026-03-26 10:59:59 +07:00
Mohamed Boudra 3fcc1ecedb Clarify getting started with two paths: desktop app and headless 2026-03-26 09:57:42 +07:00
Mohamed Boudra 46d7512cc4 fix(website): correct linux release asset links 2026-03-26 09:17:49 +07:00
Mohamed Boudra 00e172798b Update website download CLI command 2026-03-25 23:50:27 +07:00
José AlbornozandMohamed Boudra bb560809c7 Add support for Nix and NixOS (#130)
* fix: add missing resolved/integrity fields to package-lock.json

npm omits resolved URLs and integrity hashes for workspace-local
node_modules overrides. This breaks offline installers like Nix's
npm ci. Add the missing fields for 25 workspace-hoisted packages.

* feat: add Nix flake with package and NixOS module

Add a Nix flake that builds the Paseo daemon (server + CLI) and
provides a NixOS module for declarative deployment.

Package (nix/package.nix):
- Builds relay, server, and CLI workspaces
- Skips onnxruntime-node install script (sandbox-incompatible)
- Rebuilds only node-pty for native terminal support
- Source filter excludes app/website/desktop workspaces

NixOS module (nix/module.nix):
- Systemd service with configurable user, port, listen address
- allowedHosts for DNS rebinding protection
- relay.enable to toggle remote access via app.paseo.sh
- inheritUserEnvironment to expose user tools (git, ssh) to agents
- openFirewall and extra environment variables

ci: add Nix hash maintenance scripts and workflows

scripts/fix-lockfile.mjs:
  Adds missing resolved/integrity fields to package-lock.json for
  workspace-local overrides. Idempotent, uses `npm view`.

scripts/update-nix.sh:
  Runs fix-lockfile.mjs, prefetches deps, computes NAR hash, and
  updates npmDepsHash in nix/package.nix. Supports --check for CI.

.github/workflows/nix-build.yml:
  Builds the Nix package on push/PR and verifies the lockfile and
  hash are up to date.

.github/workflows/fix-nix-hash.yml:
  Auto-fixes lockfile signatures and Nix hash on dependabot PRs.

fix: update npmDepsHash after upstream sync

nix: allowlist workspace symlinks instead of blocklist

Prevents build failures when upstream adds new workspace packages.

* don't block PRs on nix failures

* better document npm workaround

* fix hash update script, and update hash

* integrate with npm run build:daemon

* ci: trigger nix build on highlight changes

* fix(nix): update npmDepsHash

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-03-25 23:42:44 +07:00
Mohamed Boudra 765a11c7ab fix(metadata): update project contact email 2026-03-25 23:36:55 +07:00
Mohamed Boudra 08e37540eb refactor(desktop): sync package metadata from root 2026-03-25 23:34:02 +07:00
Mohamed Boudra 1768e2787f fix(desktop): align website assets and linux package metadata 2026-03-25 23:31:41 +07:00
Mohamed Boudra 6d467d4659 refactor(website): extract shared download constants and icons into ~/downloads
Single source of truth for version, release URLs, store URLs, platform
detection, and shared icon components between landing page and /download.
2026-03-25 23:21:47 +07:00
Mohamed Boudra 701480c11c Add /download page and link in nav
Desktop (macOS, Windows, Linux with AppImage/DEB/RPM), Mobile (Android/iOS),
and Web & CLI sections. Simplify homepage download button to a single link
with "All download options" text link below.
2026-03-25 23:06:35 +07:00
Mohamed Boudra 9ce6b45cf9 fix(desktop): make mac artifact names explicit and add deb/rpm 2026-03-25 23:05:17 +07:00
Mohamed Boudra 7eebee2de0 Fix Claude model catalog CI assertion 2026-03-25 22:49:14 +07:00
Mohamed Boudra ff2fc72581 chore(release): cut 0.1.34 2026-03-25 22:33:28 +07:00
Mohamed Boudra cdd7071885 docs(changelog): draft 0.1.34 notes 2026-03-25 22:33:04 +07:00
Mohamed Boudra 30cba9550e Fix attach button trigger and Claude interrupt test race 2026-03-25 22:31:24 +07:00
Mohamed Boudra 1318372e81 Remove dead agent-prompt and terminal-mcp code
Delete unused system-prompt.ts, agent-prompt.md, and the entire
terminal-mcp/ directory (tmux-based MCP server) — nothing imports
any of these. The codebase uses src/terminal/ (node-pty) instead.
2026-03-25 21:54:34 +07:00
Mohamed Boudra 185990f46b Update project row workspace action icon 2026-03-25 21:01:32 +07:00
Mohamed Boudra 508cd524a8 Tighten Claude partial tool input parsing 2026-03-25 20:38:43 +07:00
Mohamed Boudra 08f25f065a Fix stale abort result contaminating replacement turns after interrupt
When interrupting a Claude agent mid-tool-call and sending a replacement
message, the SDK's abort error result was being attributed to the new
foreground turn, causing [System Error] banners and displaced replies.

Root cause: pendingInterruptAbort was cleared by visible activity from
the new turn before the stale result arrived (timing race), and the
stale result then poisoned the replacement turn.

Fix:
- Suppress stale non-success results at the top of routeSdkMessageFromPump
  before any turn attribution, using both flag-based (pendingInterruptAbort)
  and content-based (isAbortError) detection
- Stop clearing pendingInterruptAbort on visible activity — only consume
  it when a result message arrives
- Prevent idle flash during replacement by checking pendingReplacement
  in agent-manager turn_completed/turn_canceled handlers
- Fix vitest env loading to use import.meta.url instead of process.cwd()
- Load .env.test eagerly in agent-configs.ts for collection-time availability

Includes regression tests covering the exact f160a2a3 daemon log ordering.
2026-03-25 20:12:18 +07:00
Mohamed Boudra 36327cad00 Eliminate "Default" from thinking selector, always resolve to a real option
Like the model selector, the thinking selector now always resolves to an
actual thinking option ID instead of falling back to "Default" display text.
2026-03-25 19:04:01 +07:00
Mohamed Boudra 939b5c22db Fix --no-wait flag never working on paseo send
Commander's --no-* pattern sets options.wait = false, not
options.noWait = true. The check was always falsy so every
send waited up to 10 minutes for the agent to finish.
2026-03-25 18:13:27 +07:00
Mohamed Boudra 5567652c6c Add top-level paseo archive as alias for paseo agent archive 2026-03-25 17:46:32 +07:00
Mohamed Boudra a65f4c1465 Link Android button and footer to Google Play Store 2026-03-25 17:46:17 +07:00
Mohamed Boudra 253319ee50 Complete (not cancel) autonomous turns on interrupt
When a foreground message interrupts an autonomous turn, the notification
content has already been dispatched to subscribers. Canceling says "this
turn didn't happen" — but it did. Complete it instead, preserving the
lifecycle semantics and preventing notification loss.

- Change interrupt() to call completeAutonomousTurn instead of
  cancelAutonomousTurn, with flushPendingToolCalls before completion
- Remove dead cancelAutonomousTurn method (no remaining callers)
- Fix autonomous wake tests B and C to handle notification/foreground
  timing race: when task_notification arrives during a foreground turn,
  there is no separate autonomous running edge afterwards
2026-03-25 17:37:00 +07:00
Mohamed Boudra 4c1b11f733 Revert incorrect Claude interrupt flags that broke autonomous wake
Remove `pendingInterruptAbort = false` from startTurn() and
`queryRestartNeeded = true` from requestCancel(). Both violated the
existing coordination contracts:

- pendingInterruptAbort must only be cleared by the stream pump at its
  safe consumption points, not eagerly by the turn lifecycle
- queryRestartNeeded is for transport-level restarts (config changes,
  rewind), not for normal interrupt/cancel flows — setting it on every
  cancel killed the Claude process and destroyed background tasks

The actual fix for the send-during-tool-call bug was the manager-side
pendingForegroundRun settlement wait added in the previous commit.
2026-03-25 17:22:46 +07:00
Mohamed Boudra 6b67e6569b Fix chat read --since to accept message IDs
The --since filter was comparing ISO timestamps lexicographically against
message IDs (msg-*), which always filtered out every message. Now resolves
message IDs to their file first and skips messages up to that point.
2026-03-25 17:18:53 +07:00
Mohamed Boudra f5a28a434d Restore per-provider form preferences and remove Auto model fallback
- Add per-provider preference storage: model, mode, thinkingByModel
- resolveFormState reads stored prefs as fallback between initial
  values and provider defaults
- setProviderFromUser seeds model/mode/thinking from stored prefs
  when switching providers
- persistFormPreferences writes full per-provider state on agent create
- Remove workingDir and serverId from stored preferences (redundant
  with workspace tab context)
- Simplify settings.tsx by removing preference-based server resolution
2026-03-25 16:46:35 +07:00
Mohamed Boudra 638c633a21 Fix Claude agent interrupt race and refactor mode color tiers
- Fix race in agent-manager where pendingRun wasn't fully cleaned up
  before the next streamAgent call, causing "already has an active run"
- Fix Claude agent query restart: null out query/input before awaiting
  old iterator return so the old pump skips failActiveTurns
- Reset pendingInterruptAbort on new foreground turn
- Add system error assertion to send-during-tool-call e2e test
- Rename mode color tiers: drop "default", rename "readonly" to
  "planning", update color assignments across providers and UI
2026-03-25 16:46:23 +07:00
Mohamed Boudra 935e55eb27 Prune wrong-platform native binaries from Electron builds
Strips onnxruntime-node (linux/win32), claude-agent-sdk ripgrep
(non-target platforms), node-pty prebuilds, and sharp libvips from
the app.asar.unpacked directory during afterPack. Saves ~80 MB on
macOS arm64 builds.
2026-03-25 16:43:19 +07:00
Mohamed Boudra c4782ec71c Fix nested Claude Code session detection and centralize provider availability checks
Strip parent Claude Code session env vars (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, etc.)
from child agent environments so spawned agents don't fail with "cannot be launched
inside another Claude Code session". Centralize isProviderAvailable to check both
binary and credential availability. Add red e2e test for send-during-tool-call bug
and force-cancel stale foreground turns in cancelAgentRun.
2026-03-25 16:00:24 +07:00
Mohamed Boudra 81ee887d03 Reduce unnecessary re-renders in agent panel and input components
Narrow zustand selectors to only subscribe to fields each component
actually uses. Split nested objects (availableModes, projectPlacement)
into separate selectors with structural equality checks. Remove the
updatedAt processing-spinner hack in agent-input-area — the server
already guarantees agent status is "running" before ACKing the send
request, so isProcessing can clear on submit success directly.
2026-03-25 13:09:17 +07:00
Mohamed Boudra c2c5bc815e Fix Claude stderr diagnostics and hide max effort 2026-03-25 11:46:35 +07:00
Mohamed Boudra bd635cf07e test: add e2e test for model resolution on agent init 2026-03-25 10:50:20 +07:00
Mohamed Boudra ad565c2555 Fix agent input focus scoping 2026-03-24 23:41:41 +07:00
Mohamed Boudra ac991e064c Improve codex activity log tool-call summaries 2026-03-24 23:39:41 +07:00
Mohamed Boudra fadeca82eb Use parsed Claude models and remove auto defaults 2026-03-24 23:32:23 +07:00
Mohamed Boudra 891dbb0f6e Fix terminal snapshot ordering on subscribe 2026-03-24 23:06:17 +07:00
Mohamed Boudra 140e0ef965 fix(chat): skip archived agent notifications 2026-03-24 22:48:40 +07:00
Mohamed Boudra 8c49f74fd9 docs(skills): add @mention guidance for chat notifications
Orchestrator skill: tell agents to @mention you when you expect a
response back, using $PASEO_AGENT_ID. Chat skill: clarify that
mentions interrupt immediately, so use them deliberately.
2026-03-24 21:53:01 +07:00
Mohamed Boudra 535774a235 fix(chat): improve transcript readability with box-drawing borders
Replace flat markdown headers and --- dividers with box-drawing
characters (┌─ │ └─) so each message is visually distinct. Clean
up timestamp format and trim leading blank lines from message bodies.
2026-03-24 21:37:15 +07:00
Mohamed Boudra fa45ab593b chore: update orchestrator/chat skills and dev tooling
- Orchestrator skill: add Claude vs Codex guidelines, structured audit
  patterns, narrow single-purpose review passes
- Chat skill: improved coordination helpers
- Dev script and config tweaks
2026-03-24 21:22:14 +07:00
Mohamed Boudra a54687d3ef refactor(server): replace stream() with subscribe() + startTurn() across all providers
Replace three competing event paths (foreground stream, live event pump,
JSONL history poller) with a single push-based subscribe() + startTurn()
contract. This fixes duplicate user messages and stuck running state caused
by timing-based routing between concurrent event sources.

Key changes:
- AgentSession interface: remove stream(), add subscribe() and startTurn()
- All providers (Claude, Codex, OpenCode): single subscribers Set with
  notifySubscribers() for push-based event delivery and turnId stamping
- Agent manager: identity-based turn ownership via activeForegroundTurnId
  replacing pendingRun async generator
- Delete: dual queues, routeSdkMessageFromPump, startLiveHistoryPolling,
  snapHistoryOffsetToEnd, liveEventBacklog, Pushable
- Fix Codex provider not clearing activeForegroundTurnId on turn completion
- Add real-provider integration tests for event stream invariants
2026-03-24 21:22:00 +07:00
Mohamed Boudra 6cc81e3ab4 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-24 14:08:57 +07:00
Mohamed Boudra 7699d2fcbd Expose PASEO_AGENT_ID to Claude and Codex agents (#143)
* Expose PASEO_AGENT_ID to managed agents

* refactor(server): make managed agent launch context explicit

* refactor(server): pass launch env through agent providers

* fix: use workspace-agnostic root tsconfig

* fix(server): align CI tests with current agent behavior

* fix(server): restore Claude history and sidechain CI coverage
2026-03-24 15:02:15 +08:00
Mohamed Boudra 77cdfbcb06 fix(app): sync keyboard pane focus with active panel (#141)
* fix(app): move keyboard focus to target pane on keyboard navigation (#137)

Add a pane focus registry so each panel type can expose its own focus
callback. When keyboard shortcuts navigate between panes, the workspace
screen calls the target panel's registered focus function, moving DOM
focus to the correct input element.

- Agent panels register a callback that focuses the message input
- Terminal panels register a callback that triggers terminal focus
- Panel-internal focus logic stays inside each panel type
- No DOM selectors or panel knowledge in shared infrastructure

* fix(app): simplify workspace pane focus handoff
2026-03-24 15:02:02 +08:00
Mohamed Boudra cbab9332a9 feat(app): redesign command autocomplete with detail card and dropdown styling
- Add detail card above command list showing full description and argument hint
- Compact command rows to single line (label + truncated description)
- Match dropdown component styling (surface1 bg, borderAccent border, shadow)
2026-03-24 13:35:59 +07:00
Mohamed Boudra fa0346921a feat(skills): add paseo chat coordination 2026-03-24 12:37:13 +07:00
Mohamed Boudra c6aff35db2 fix(app): restore assistant text selection on web (#140) 2026-03-24 12:01:08 +08:00
Mohamed Boudra af8509d012 fix(release): unblock 0.1.33 app and server checks 2026-03-24 00:09:05 +07:00
Mohamed Boudra 0e6aba2886 refactor(paseo-loop): support self and new-agent targets with max-time 2026-03-24 00:04:17 +07:00
Mohamed Boudra e969f42c68 chore(release): cut 0.1.33 2026-03-23 23:21:14 +07:00
Mohamed Boudra 876c08ebc2 docs: add 0.1.33 changelog 2026-03-23 23:13:01 +07:00
Mohamed Boudra 8ba5df358a Revert "debug(app): add dictation pipeline logging"
This reverts commit e36784e510.
2026-03-23 23:06:55 +07:00
Mohamed Boudra 771acd11a1 fix(server): close query stream before interrupt/return in claude agent
Call query.close() before awaiting interrupt/return to ensure the
stream is properly torn down on session close and query restart.
2026-03-23 22:04:37 +07:00
Mohamed Boudra d4735edd45 fix(desktop): surface notification test errors in UI
Show inline error text when the test notification fails or isn't
delivered instead of silently logging to console.
2026-03-23 22:04:30 +07:00
Mohamed Boudra e36784e510 debug(app): add dictation pipeline logging
Trace the full dictation flow — confirm, transcript, and auto-send
paths — to diagnose transcription delivery issues.
2026-03-23 22:04:24 +07:00
Mohamed Boudra a237285808 fix(desktop): resolve to Helper app for daemon child processes on macOS
When packaged, Electron's process.execPath points to the main app
binary which inherits the full app entitlements and UI lifecycle.
Resolve to the Helper binary instead so daemon child processes run
without inheriting the main app's entitlement/UI baggage.
2026-03-23 22:04:19 +07:00
Mohamed Boudra ee6d8072ed feat(desktop): add microphone entitlement for dictation
Add com.apple.security.device.audio-input to both mac entitlement
plists so the sandboxed app can access the microphone.
2026-03-23 22:04:12 +07:00
Mohamed Boudra c8d259b1df feat(highlight): make package public and add to release pipeline
Remove private flag, add publishConfig with public access, point
types to dist output, and include highlight in release:check,
release:publish, and dry-run scripts.
2026-03-23 22:04:02 +07:00
Mohamed Boudra 2da56634a1 refactor(skills): strip orchestrator concerns from paseo skill
Paseo skill is now a pure CLI reference — commands, models,
permissions, waiting guidelines, and bash composition patterns.
Orchestrator-specific guidance (agent interrogation, investigation
vs implementation, management principles, committee patterns) moved
to the new paseo-orchestrator skill.
2026-03-23 21:52:36 +07:00
Mohamed Boudra 27be8c3b1e feat(skills): add paseo-orchestrator skill for agent orchestration
Dedicated skill for orchestrator mode — how to manage agents as a
product owner rather than a coder. Covers two-audience model (design
partner to user, product owner to agents), pre-launch logistics,
agent types beyond just impl/review, prompt structure, mandatory
review step, challenging agent bad behaviors (hand-waving,
over-engineering, lying, working around problems), and user signal
interpretation.
2026-03-23 21:52:30 +07:00
199 changed files with 11475 additions and 7318 deletions
+3
View File
@@ -26,6 +26,9 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck - name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app run: npm run typecheck --workspace=@getpaseo/app
+3 -3
View File
@@ -124,7 +124,7 @@ jobs:
release_type="release" release_type="release"
fi fi
else else
release_type="release" release_type="draft"
fi fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -230,7 +230,7 @@ jobs:
release_type="release" release_type="release"
fi fi
else else
release_type="release" release_type="draft"
fi fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +339,7 @@ jobs:
release_type="release" release_type="release"
fi fi
else else
release_type="release" release_type="draft"
fi fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
+45
View File
@@ -0,0 +1,45 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
+59
View File
@@ -0,0 +1,59 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check
+3
View File
@@ -36,6 +36,9 @@ jobs:
- name: Install server dependencies - name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency - name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay run: npm run build --workspace=@getpaseo/relay
+61
View File
@@ -1,5 +1,66 @@
# Changelog # Changelog
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
- The notification test button now surfaces errors instead of failing silently.
## 0.1.32 - 2026-03-23 ## 0.1.32 - 2026-03-23
### Added ### Added
+3 -5
View File
@@ -46,9 +46,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER add auth checks to tests** — agent providers handle their own auth. - **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.** - **Always run typecheck after every change.**
## Orchestrator mode ## Debugging
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
- Launch agents in the most permissive mode Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
- Set cwd to the repository root
- When agent control tool calls fail, list agents first — it may be a wait timeout
+61 -9
View File
@@ -4,40 +4,88 @@
<h1 align="center">Paseo</h1> <h1 align="center">Paseo</h1>
<p align="center">One interface for all your coding agents.</p> <p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center"> <p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%"> <img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p> </p>
--- ---
Run agents in parallel on your own machines. Ship from your phone or your desk. Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job. - **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
## Getting Started ## Getting Started
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install. ### Desktop app
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
### Headless / server mode ### Headless / server mode
To run the daemon on a remote or headless machine: Run the daemon on any machine:
```bash ```bash
npm install -g @getpaseo/cli npm install -g @getpaseo/cli
paseo paseo
``` ```
Then connect from the desktop app, mobile app, or CLI. Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
For full setup and configuration, see: For full setup and configuration, see:
- [Docs](https://paseo.sh/docs) - [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration) - [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development ## Development
Quick monorepo package map: Quick monorepo package map:
@@ -57,8 +105,12 @@ npm run dev
# run individual surfaces # run individual surfaces
npm run dev:server npm run dev:server
npm run dev:app npm run dev:app
npm run dev:desktop
npm run dev:website npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks # repo-wide checks
npm run typecheck npm run typecheck
``` ```
+1 -1
View File
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities ## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue. If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
+16 -3
View File
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
## Manual step-by-step ## Manual step-by-step
```bash ```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows) npm run release:push # Push HEAD + tag (triggers CI workflows)
``` ```
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```bash ```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published npm run release:finalize # Publish npm, promote draft to published
``` ```
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release - `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag - Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop` - Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build ## Fixing a failed release build
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types - `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop` - `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions - If `release:publish` partially fails, re-run it — npm skips already-published versions
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time - The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Completion checklist ## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors) - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `npm run release:patch` completes successfully - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green - [ ] EAS `release-mobile.yml` workflow for the same tag is green
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+59
View File
@@ -0,0 +1,59 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}
+172
View File
@@ -0,0 +1,172 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
+144
View File
@@ -0,0 +1,144 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# 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-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
# 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).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (daemon-runner, supervisor) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+80 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.32", "version": "0.1.37",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.32", "version": "0.1.37",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -16718,6 +16718,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/electron-log": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": { "node_modules/electron-publish": {
"version": "26.8.1", "version": "26.8.1",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
@@ -34842,16 +34851,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32", "@getpaseo/expo-two-way-audio": "0.1.37",
"@getpaseo/highlight": "*", "@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -34937,6 +34946,8 @@
}, },
"packages/app/node_modules/expo-clipboard": { "packages/app/node_modules/expo-clipboard": {
"version": "8.0.7", "version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -34956,6 +34967,8 @@
}, },
"packages/app/node_modules/zod": { "packages/app/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
@@ -34963,11 +34976,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.32", "@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -34987,6 +35000,8 @@
}, },
"packages/cli/node_modules/chalk": { "packages/cli/node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0" "node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -34997,6 +35012,8 @@
}, },
"packages/cli/node_modules/commander": { "packages/cli/node_modules/commander": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -35004,10 +35021,12 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.32", "version": "0.1.37",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.32", "@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
@@ -35040,7 +35059,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.32", "version": "0.1.37",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -35241,7 +35260,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -35267,7 +35286,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -35283,13 +35302,13 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "*", "@getpaseo/highlight": "0.1.37",
"@getpaseo/relay": "0.1.32", "@getpaseo/relay": "0.1.37",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
@@ -35334,6 +35353,8 @@
}, },
"packages/server/node_modules/@modelcontextprotocol/sdk": { "packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.6", "ajv": "^6.12.6",
@@ -35355,6 +35376,8 @@
}, },
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -35369,6 +35392,8 @@
}, },
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": { "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
@@ -35415,6 +35440,8 @@
}, },
"packages/server/node_modules/accepts": { "packages/server/node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
@@ -35426,6 +35453,8 @@
}, },
"packages/server/node_modules/ajv": { "packages/server/node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -35440,6 +35469,8 @@
}, },
"packages/server/node_modules/ansi-regex": { "packages/server/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -35450,6 +35481,8 @@
}, },
"packages/server/node_modules/body-parser": { "packages/server/node_modules/body-parser": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
@@ -35468,6 +35501,8 @@
}, },
"packages/server/node_modules/content-disposition": { "packages/server/node_modules/content-disposition": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "5.2.1" "safe-buffer": "5.2.1"
@@ -35478,6 +35513,8 @@
}, },
"packages/server/node_modules/cookie-signature": { "packages/server/node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.6.0" "node": ">=6.6.0"
@@ -35485,6 +35522,8 @@
}, },
"packages/server/node_modules/finalhandler": { "packages/server/node_modules/finalhandler": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
@@ -35500,6 +35539,8 @@
}, },
"packages/server/node_modules/fresh": { "packages/server/node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
@@ -35507,6 +35548,8 @@
}, },
"packages/server/node_modules/media-typer": { "packages/server/node_modules/media-typer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
@@ -35514,6 +35557,8 @@
}, },
"packages/server/node_modules/merge-descriptors": { "packages/server/node_modules/merge-descriptors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -35524,6 +35569,8 @@
}, },
"packages/server/node_modules/mime-types": { "packages/server/node_modules/mime-types": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
@@ -35534,6 +35581,8 @@
}, },
"packages/server/node_modules/negotiator": { "packages/server/node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@@ -35572,6 +35621,8 @@
}, },
"packages/server/node_modules/send": { "packages/server/node_modules/send": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.5", "debug": "^4.3.5",
@@ -35592,6 +35643,8 @@
}, },
"packages/server/node_modules/serve-static": { "packages/server/node_modules/serve-static": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
@@ -35605,6 +35658,8 @@
}, },
"packages/server/node_modules/strip-ansi": { "packages/server/node_modules/strip-ansi": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.0.1" "ansi-regex": "^6.0.1"
@@ -35618,6 +35673,8 @@
}, },
"packages/server/node_modules/type-is": { "packages/server/node_modules/type-is": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -35630,6 +35687,8 @@
}, },
"packages/server/node_modules/zod": { "packages/server/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
@@ -35637,7 +35696,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.32", "version": "0.1.37",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
@@ -35663,6 +35722,8 @@
}, },
"packages/website/node_modules/@types/node": { "packages/website/node_modules/@types/node": {
"version": "22.19.6", "version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+19 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.32", "version": "0.1.37",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
@@ -44,18 +44,18 @@
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"", "version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"", "version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"", "version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs", "release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release", "draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push", "draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push", "draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push", "draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs", "release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push", "release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push", "release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" "release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8", "@biomejs/biome": "^2.4.8",
@@ -68,6 +68,7 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [ "keywords": [
"openai", "openai",
"realtime", "realtime",
@@ -76,7 +77,14 @@
"development", "development",
"mcp" "mcp"
], ],
"author": "moboudra", "author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"overrides": { "overrides": {
"lightningcss": "1.30.1", "lightningcss": "1.30.1",
+9 -6
View File
@@ -448,12 +448,15 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays. PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_DICTATION_ENABLED: "1", PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: "1", ...(openAiUsable
PASEO_DICTATION_STT_PROVIDER: dictationProvider, ? {
PASEO_VOICE_STT_PROVIDER: "openai", PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai", PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development", NODE_ENV: "development",
}, },
+228
View File
@@ -0,0 +1,228 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,160 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.32", "version": "0.1.37",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32", "@getpaseo/expo-two-way-audio": "0.1.37",
"@getpaseo/highlight": "*", "@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -1,36 +0,0 @@
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false;
}
if (input.hasTimedOut) {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false;
}
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
+20 -7
View File
@@ -25,6 +25,9 @@ import {
useHostMutations, useHostMutations,
useHostRuntimeClient, useHostRuntimeClient,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { import {
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
let cancelled = false; let cancelled = false;
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
void store const init = async () => {
.loadFromStorage() const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
await store.bootstrap({ manageBuiltInDaemon: true });
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
}
};
void init()
.then(() => { .then(() => {
if (cancelled) { if (!cancelled) {
return; setReady(true);
} }
setReady(true);
void store.bootstrap();
}) })
.catch((error) => { .catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error); console.error("[HostRuntime] Failed to initialize store", error);
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [isLoading, settings.theme]); }, [isLoading, settings.theme]);
if (isLoading) { if (isLoading) {
return <LoadingView />; const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
} }
return ( return (
+3 -98
View File
@@ -1,113 +1,18 @@
import { useEffect, useSyncExternalStore, useState } from "react"; import { useEffect } from "react";
import { usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000; const WELCOME_ROUTE = "/welcome";
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => { useEffect(() => {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") { if (pathname !== "/" && pathname !== "") {
return; return;
} }
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any); router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]); }, [pathname, router]);
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null; return null;
} }
+2 -34
View File
@@ -1,55 +1,23 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes"; import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() { export default function LegacySettingsRoute() {
const router = useRouter(); const router = useRouter();
const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => { const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null; return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]); }, [daemons]);
useEffect(() => { useEffect(() => {
if (preferencesLoading) {
return;
}
if (!targetServerId) { if (!targetServerId) {
return; return;
} }
router.replace(buildHostSettingsRoute(targetServerId) as any); router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, router, targetServerId]); }, [router, targetServerId]);
if (preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
if (!targetServerId) { if (!targetServerId) {
return <DraftAgentScreen />; return <DraftAgentScreen />;
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
function getModeColor(modeId?: string): string { function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray if (!modeId) return "#9ca3af"; // gray
// Color based on common mode types if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
if (modeId.includes("code")) return "#22c55e"; // green - writes code return "#3b82f6"; // blue - build/auto
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
return "#22c55e"; // green - safest
return "#9ca3af"; // gray - unknown return "#9ca3af"; // gray - unknown
} }
@@ -537,14 +537,10 @@ export function AgentConfigRow({
}, [modeOptions]); }, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => { const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }]; return models.map((model) => ({
for (const model of models) { id: model.id,
opts.push({ label: model.label,
id: model.id, }));
label: model.label,
});
}
return opts;
}, [models]); }, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo( const thinkingSelectOptions: ComboSelectOption[] = useMemo(
@@ -586,7 +582,7 @@ export function AgentConfigRow({
title="Select model" title="Select model"
value={selectedModel} value={selectedModel}
options={modelSelectOptions} options={modelSelectOptions}
placeholder="Auto" placeholder={isModelLoading ? "Loading..." : "Select model"}
disabled={disabled} disabled={disabled}
isLoading={isModelLoading} isLoading={isModelLoading}
onSelect={onSelectModel} onSelect={onSelectModel}
@@ -777,10 +773,8 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel) const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error const helperText = error
? undefined ? undefined
: isLoading : isLoading
@@ -790,34 +784,20 @@ export function ModelDropdown({
: undefined; : undefined;
const options = useMemo(() => { const options = useMemo(() => {
const opts: ComboSelectOption[] = [ return models.map((model) => ({
{ id: model.id,
id: "", label: model.label,
label: "Automatic (provider default)", description: model.description,
description: "Let the assistant pick the recommended model.", }));
},
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
}, [models]); }, [models]);
const handleOpen = useCallback(() => setIsOpen(true), []); const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []); const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string) => { (id: string) => {
if (id === "") { onSelect(id);
onClear();
} else {
onSelect(id);
}
}, },
[onClear, onSelect], [onSelect],
); );
return ( return (
@@ -1,10 +1,10 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream"; import { generateMessageId, type StreamItem } from "@/types/stream";
import { import {
@@ -59,6 +59,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface AgentInputAreaProps { interface AgentInputAreaProps {
agentId: string; agentId: string;
serverId: string; serverId: string;
isInputActive: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
@@ -91,6 +92,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({ export function AgentInputArea({
agentId, agentId,
serverId, serverId,
isInputActive,
onSubmitMessage, onSubmitMessage,
isSubmitLoading = false, isSubmitLoading = false,
blurOnSubmit = false, blurOnSubmit = false,
@@ -112,8 +114,6 @@ export function AgentInputArea({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isScreenFocused = useIsFocused();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -127,7 +127,14 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" || agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready"); agentDirectoryStatus === "error_after_ready");
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)); const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
};
}),
);
const queuedMessagesRaw = useSessionStore((state) => const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId), state.sessions[serverId]?.queuedMessages?.get(agentId),
@@ -260,7 +267,6 @@ export function AgentInputArea({
return updated; return updated;
}); });
} }
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, { await client.sendAgentMessage(agentId, text, {
messageId: clientMessageId, messageId: clientMessageId,
@@ -274,41 +280,8 @@ export function AgentInputArea({
onSubmitMessageRef.current = onSubmitMessage; onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]); }, [onSubmitMessage]);
const isAgentRunning = agent?.status === "running"; const isAgentRunning = agentState.status === "running";
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0; const hasAgent = agentState.status !== null;
const prevIsAgentRunningRef = useRef(isAgentRunning);
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
if (agentUpdatedAtMs < previousUpdatedAt) {
if (isProcessing && !isAgentRunning) {
prevIsAgentRunningRef.current = false;
setIsProcessing(false);
}
return;
}
const wasRunning = prevIsAgentRunningRef.current;
let shouldClearProcessing = false;
if (isProcessing) {
const hasEnteredRunning = !wasRunning && isAgentRunning;
const hasFreshRunningUpdateWhileRunning =
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
const hasStoppedRunning = wasRunning && !isAgentRunning;
shouldClearProcessing =
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
}
prevIsAgentRunningRef.current = isAgentRunning;
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
if (shouldClearProcessing) {
setIsProcessing(false);
}
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
const updateQueue = useCallback( const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => { (updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
@@ -350,7 +323,7 @@ export function AgentInputArea({
message, message,
imageAttachments, imageAttachments,
forceSend, forceSend,
isAgentRunning: agent?.status === "running", isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the // Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode. // transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
@@ -424,7 +397,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback( const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) { if (!isInputActive) {
return false; return false;
} }
@@ -460,7 +433,7 @@ export function AgentInputArea({
return false; return false;
} }
}, },
[isScreenFocused], [isInputActive],
); );
useKeyboardActionHandler({ useKeyboardActionHandler({
@@ -472,9 +445,9 @@ export function AgentInputArea({
"message-input.voice-toggle", "message-input.voice-toggle",
"message-input.voice-mute-toggle", "message-input.voice-mute-toggle",
], ],
enabled: isScreenFocused, enabled: isInputActive,
priority: isMessageInputFocused ? 200 : 100, priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused, isActive: () => isInputActive,
handle: handleKeyboardAction, handle: handleKeyboardAction,
}); });
@@ -483,7 +456,7 @@ export function AgentInputArea({
}); });
function handleCancelAgent() { function handleCancelAgent() {
if (!agent || agent.status !== "running" || isCancellingAgent) { if (!isAgentRunning || isCancellingAgent) {
return; return;
} }
if (!isConnected || !client) { if (!isConnected || !client) {
@@ -497,7 +470,7 @@ export function AgentInputArea({
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false; const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => { const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected || !agent) { if (!voice || !isConnected || !hasAgent) {
return; return;
} }
if (voice.isVoiceSwitching) { if (voice.isVoiceSwitching) {
@@ -514,7 +487,7 @@ export function AgentInputArea({
toast.error(message); toast.error(message);
} }
}); });
}, [agent, agentId, isConnected, serverId, toast, voice]); }, [agentId, hasAgent, isConnected, serverId, toast, voice]);
function handleEditQueuedMessage(id: string) { function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id); const item = queuedMessages.find((q) => q.id === id);
@@ -606,7 +579,7 @@ export function AgentInputArea({
const rightContent = ( const rightContent = (
<View style={styles.rightControls}> <View style={styles.rightControls}>
{!isVoiceModeForAgent && agent ? ( {!isVoiceModeForAgent && hasAgent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleToggleRealtimeVoice} onPress={handleToggleRealtimeVoice}
@@ -716,7 +689,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`} autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
isScreenFocused={isScreenFocused} isInputActive={isInputActive}
leftContent={leftContent} leftContent={leftContent}
rightContent={rightContent} rightContent={rightContent}
voiceServerId={serverId} voiceServerId={serverId}
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
try { try {
await input.submitMessage({ message: trimmedMessage, imageAttachments }); await input.submitMessage({ message: trimmedMessage, imageAttachments });
input.clearDraft("sent"); input.clearDraft("sent");
input.setIsProcessing(false);
return "submitted"; return "submitted";
} catch (error) { } catch (error) {
input.onSubmitError?.(error); input.onSubmitError?.(error);
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
}); });
describe("normalizeModelId", () => { describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => { it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull(); expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull(); expect(normalizeModelId(undefined)).toBeNull();
}); });
it("returns trimmed model ids", () => { it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex"); expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
expect(normalizeModelId(" default ")).toBe("default");
}); });
}); });
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
expect(selection.selectedThinkingId).toBe("high"); expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High"); expect(selection.displayThinking).toBe("High");
}); });
it("falls back to the provider default model label instead of Auto", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "codex",
label: "Model A",
isDefault: true,
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: null,
configuredModelId: null,
explicitThinkingOptionId: null,
});
expect(selection.displayModel).toBe("Model A");
expect(selection.displayThinking).toBe("Low");
});
it("prefers the configured model when runtime model is not in the model list", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "default",
provider: "claude",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
],
runtimeModelId: "claude-sonnet-4-6-20260101",
configuredModelId: "default",
explicitThinkingOptionId: null,
});
expect(selection.activeModelId).toBe("default");
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
expect(selection.selectedThinkingId).toBe("low");
expect(selection.displayThinking).toBe("Low");
});
}); });
@@ -1,6 +1,8 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -104,21 +106,18 @@ function getModeIconColor(
palette: { palette: {
blue: { 500: string }; blue: { 500: string };
green: { 500: string }; green: { 500: string };
amber: { 500: string };
red: { 500: string }; red: { 500: string };
purple: { 500: string }; purple: { 500: string };
}, },
): string { ): string {
switch (colorTier) { switch (colorTier) {
case "default":
return palette.blue[500];
case "safe": case "safe":
return palette.green[500]; return palette.green[500];
case "moderate": case "moderate":
return palette.amber[500]; return palette.blue[500];
case "dangerous": case "dangerous":
return palette.red[500]; return palette.red[500];
case "readonly": case "planning":
return palette.purple[500]; return palette.purple[500];
default: default:
return palette.blue[500]; return palette.blue[500];
@@ -166,8 +165,12 @@ function ControlledStatusBar({
const displayModel = const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0) isModelLoading && (!modelOptions || modelOptions.length === 0)
? "Loading models..." ? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Auto"); : findOptionLabel(modelOptions, selectedModelId, "Select model");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto"); const displayThinking = findOptionLabel(
thinkingOptions,
selectedThinkingOptionId,
thinkingOptions?.[0]?.label ?? "Unknown",
);
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
@@ -603,8 +606,29 @@ function ControlledStatusBar({
); );
} }
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)); const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return currentAgent
? {
provider: currentAgent.provider,
cwd: currentAgent.cwd,
currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
thinkingOptionId: currentAgent.thinkingOptionId,
}
: null;
}),
);
const availableModes = useStoreWithEqualityFn(
useSessionStore,
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({ const modelsQuery = useQuery({
@@ -631,23 +655,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const models = modelsQuery.data ?? null; const models = modelsQuery.data ?? null;
const displayMode = const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
agent?.currentModeId || agent?.currentModeId ||
"default"; "default";
const modelSelection = resolveAgentModelSelection({ const modelSelection = resolveAgentModelSelection({
models, models,
runtimeModelId: agent?.runtimeInfo?.model, runtimeModelId: agent?.runtimeModelId,
configuredModelId: agent?.model, configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId, explicitThinkingOptionId: agent?.thinkingOptionId,
}); });
const modeOptions = useMemo<StatusOption[]>(() => { const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({ return availableModes.map((mode) => ({
id: mode.id, id: mode.id,
label: mode.label, label: mode.label,
})); }));
}, [agent?.availableModes]); }, [availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => { const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label })); return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
@@ -668,9 +692,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ControlledStatusBar <ControlledStatusBar
provider={agent.provider} provider={agent.provider}
modeOptions={ modeOptions={
modeOptions.length > 0 modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
? modeOptions
: [{ id: agent.currentModeId ?? "", label: displayMode }]
} }
selectedModeId={agent.currentModeId ?? undefined} selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => { onSelectMode={(modeId) => {
@@ -777,7 +799,7 @@ export function DraftAgentStatusBar({
label: definition.label, label: definition.label,
})); }));
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }]; const modelOptions: StatusOption[] = [];
for (const model of models) { for (const model of models) {
modelOptions.push({ id: model.id, label: model.label }); modelOptions.push({ id: model.id, label: model.label });
} }
@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
export function normalizeModelId(modelId: string | null | undefined): string | null { export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") { if (!normalized) {
return null; return null;
} }
return normalized; return normalized;
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId); const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId; const runtimeSelectedModel =
models && normalizedRuntimeModelId
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
const fallbackModel =
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel = const selectedModel =
models && preferredModelId models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null) ? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
: null; : fallbackModel;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null; const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto"; const displayModel =
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
const thinkingOptions = selectedModel?.thinkingOptions ?? null; const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId = const resolvedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== "default" explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId ? explicitThinkingOptionId
: (selectedModel?.defaultThinkingOptionId ?? null); : (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking = const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null; thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
const displayThinking = const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
selectedThinking?.label ?? const selectedThinkingId = effectiveThinking?.id ?? null;
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto")); const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
return { return {
selectedModel, selectedModel,
@@ -39,7 +39,7 @@ import {
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps { export interface AgentStreamViewProps {
agentId: string; agentId: string;
serverId?: string; serverId?: string;
agent: Agent; agent: AgentScreenAgent;
streamItems: StreamItem[]; streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string }; type DrillDownView = { provider: string };
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
interface CombinedModelSelectorProps { interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>; allProviderModels: Map<string, AgentModelDefinition[]>;
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
const selectedModelLabel = useMemo(() => { const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider); const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Auto"; if (!models) return isLoading ? "Loading..." : "Select model";
const model = models.find((m) => m.id === selectedModel); const model = models.find((m) => m.id === selectedModel);
return model?.label ?? "Auto"; return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, selectedProvider, selectedModel, isLoading]); }, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return ( return (
@@ -6,7 +6,6 @@ import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE, HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE, HEADER_TOP_PADDING_MOBILE,
getIsDesktopMac,
} from "@/constants/layout"; } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
@@ -31,8 +30,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset = const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0; !isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
@@ -42,7 +43,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
<View <View
style={[ style={[
styles.row, styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }, {
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
},
]} ]}
{...dragHandlers} {...dragHandlers}
> >
+1 -1
View File
@@ -681,7 +681,7 @@ function DesktopSidebar({
return ( return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}> <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? ( {trafficLightPadding.side === 'left' ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} /> <View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null} ) : null}
<View style={styles.sidebarHeader} {...dragHandlers}> <View style={styles.sidebarHeader} {...dragHandlers}>
+26 -20
View File
@@ -66,8 +66,8 @@ export interface MessageInputProps {
autoFocus?: boolean; autoFocus?: boolean;
autoFocusKey?: string; autoFocusKey?: string;
disabled?: boolean; disabled?: boolean;
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isScreenFocused?: boolean; isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */ /** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode; leftContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
@@ -190,7 +190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus = false, autoFocus = false,
autoFocusKey, autoFocusKey,
disabled = false, disabled = false,
isScreenFocused = true, isInputActive = true,
leftContent, leftContent,
rightContent, rightContent,
voiceServerId, voiceServerId,
@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning], [onChangeText, onSubmit, images, isAgentRunning],
); );
const handleDictationError = useCallback((error: Error) => { const handleDictationError = useCallback(
console.error("[MessageInput] Dictation error:", error); (error: Error) => {
}, []); console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo, serverInfo,
@@ -387,7 +391,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError, onError: handleDictationError,
canStart: canStartDictation, canStart: canStartDictation,
canConfirm: canConfirmDictation, canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isScreenFocused }, autoStopWhenHidden: { isVisible: isInputActive },
enableDuration: true, enableDuration: true,
}); });
@@ -950,18 +954,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}> <View style={styles.leftButtonGroup}>
{onPickImages && ( {onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger asChild>
onPress={onPickImages} <Pressable
disabled={!isConnected || disabled} onPress={onPickImages}
accessibilityLabel="Attach images" disabled={!isConnected || disabled}
accessibilityRole="button" accessibilityLabel="Attach images"
style={({ hovered }) => [ accessibilityRole="button"
styles.attachButton, style={({ hovered }) => [
hovered && styles.iconButtonHovered, styles.attachButton,
(!isConnected || disabled) && styles.buttonDisabled, hovered && styles.iconButtonHovered,
]} (!isConnected || disabled) && styles.buttonDisabled,
> ]}
<Paperclip size={buttonIconSize} color={theme.colors.foreground} /> >
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text> <Text style={styles.tooltipText}>Attach images</Text>
@@ -1203,7 +1209,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: { leftButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-end", alignItems: "flex-end",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], gap: theme.spacing[1],
}, },
rightButtonGroup: { rightButtonGroup: {
flexDirection: "row", flexDirection: "row",
+2 -1
View File
@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: 13, fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
}, },
})); }));
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text <Text
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false} selectable={Platform.OS === "web" ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
> >
{content} {content}
@@ -31,11 +31,11 @@ import {
ChevronRight, ChevronRight,
Copy, Copy,
ExternalLink, ExternalLink,
FolderPlus,
FolderGit2, FolderGit2,
GitPullRequest, GitPullRequest,
Monitor, Monitor,
MoreVertical, MoreVertical,
Plus,
} from "lucide-react-native"; } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -392,15 +392,15 @@ function NewWorktreeButton({
}} }}
disabled={loading} disabled={loading}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`} accessibilityLabel={`Create a new workspace for ${displayName}`}
testID={testID} testID={testID}
> >
{({ hovered, pressed }) => {({ hovered, pressed }) =>
loading ? ( loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} /> <ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : ( ) : (
<Plus <FolderPlus
size={14} size={15}
color={ color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
} }
@@ -411,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}> <View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text> <Text style={styles.projectActionTooltipText}>New workspace</Text>
{showShortcutHint && newWorktreeKeys ? ( {showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} /> <Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null} ) : null}
@@ -886,7 +886,10 @@ function SplitPaneView({
style={[ style={[
styles.paneTabs, styles.paneTabs,
isFocusModeEnabled && isFocusModeEnabled &&
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left }, trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
]} ]}
> >
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
+134 -56
View File
@@ -150,70 +150,136 @@ export function Autocomplete({
); );
} }
const selectedOption = options[selectedIndex];
return ( return (
<View style={[styles.container, { maxHeight }]}> <View style={styles.outerWrapper}>
<ScrollView {selectedOption?.kind === "command" && selectedOption.description ? (
ref={scrollRef} <View style={styles.detailCard}>
onLayout={handleScrollViewLayout} <Text style={styles.detailLabel}>
onContentSizeChange={pinToBottom} {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
onScroll={(event) => { </Text>
scrollOffsetRef.current = event.nativeEvent.contentOffset.y; <Text style={styles.detailDescription}>
}} {removeBoltGlyphs(selectedOption.description)}
scrollEventThrottle={16} </Text>
style={styles.scrollView} {selectedOption.detail ? (
contentContainerStyle={styles.scrollContent} <Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
keyboardShouldPersistTaps="always" ) : null}
> </View>
{options.map((option, index) => { ) : null}
const isSelected = index === selectedIndex; <View style={[styles.container, { maxHeight }]}>
const optionLabel = removeBoltGlyphs(option.label) ?? option.label; <ScrollView
const optionDetail = removeBoltGlyphs(option.detail); ref={scrollRef}
const optionDescription = removeBoltGlyphs(option.description); onLayout={handleScrollViewLayout}
return ( onContentSizeChange={pinToBottom}
<Pressable onScroll={(event) => {
key={option.id} scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
onLayout={(event) => handleRowLayout(index, event)} }}
onPress={() => onSelect(option)} scrollEventThrottle={16}
style={({ hovered = false, pressed }) => [ style={styles.scrollView}
styles.item, contentContainerStyle={styles.scrollContent}
(hovered || pressed || isSelected) && styles.itemActive, keyboardShouldPersistTaps="always"
]} >
> {options.map((option, index) => {
{option.kind === "directory" || option.kind === "file" ? ( const isSelected = index === selectedIndex;
<View style={styles.itemLeading}> const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
{option.kind === "directory" ? ( const optionDescription = removeBoltGlyphs(option.description);
<Folder size={14} color={theme.colors.foregroundMuted} /> const isFileOrDir = option.kind === "directory" || option.kind === "file";
) : ( return (
<File size={14} color={theme.colors.foregroundMuted} /> <Pressable
)} key={option.id}
</View> onLayout={(event) => handleRowLayout(index, event)}
) : null} onPress={() => onSelect(option)}
<View style={styles.itemMain}> style={({ hovered = false, pressed }) => [
<View style={styles.itemHeader}> styles.item,
<Text style={styles.itemLabel}>{optionLabel}</Text> (hovered || pressed || isSelected) && styles.itemActive,
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null} ]}
</View> >
{optionDescription ? ( {isFileOrDir ? (
<Text style={styles.itemDescription} numberOfLines={1}> <>
{optionDescription} <View style={styles.itemLeading}>
</Text> {option.kind === "directory" ? (
) : null} <Folder size={14} color={theme.colors.foregroundMuted} />
</View> ) : (
</Pressable> <File size={14} color={theme.colors.foregroundMuted} />
); )}
})} </View>
</ScrollView> <View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create(((theme: Theme) => ({ const styles = StyleSheet.create(((theme: Theme) => ({
container: { outerWrapper: {
backgroundColor: theme.colors.surface0, gap: theme.spacing[1],
},
detailCard: {
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
detailLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
detailDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
detailHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
container: {
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
overflow: "hidden", overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
}, },
scrollView: { scrollView: {
flexGrow: 0, flexGrow: 0,
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
marginRight: theme.spacing[1], marginRight: theme.spacing[1],
}, },
itemActive: { itemActive: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface2,
}, },
itemMain: { itemMain: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
itemMainRow: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemHeader: { itemHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
marginTop: 2, marginTop: 2,
}, },
itemDescriptionInline: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyItem: { emptyItem: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
+149 -3
View File
@@ -1,11 +1,17 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime"; import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal"; import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal"; import { PairLinkModal } from "./pair-link-modal";
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: { actionTextPrimary: {
color: theme.colors.accentForeground, color: theme.colors.accentForeground,
}, },
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
versionLabel: { versionLabel: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps { export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void; onHostAdded?: (profile: HostProfile) => void;
} }
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null; hostname: string | null;
} | null>(null); } | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null); const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore( const pendingNameHostname = useSessionStore(
useCallback( useCallback(
(state) => { (state) => {
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
), ),
); );
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback( const finishOnboarding = useCallback(
(serverId: string) => { (serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId) as any);
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, },
]; ];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }} style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}> <View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} /> <PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text> <Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text> <Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<View style={styles.actions}> <View style={styles.actions}>
{actions.map((action) => { {actions.map((action) => {
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
); );
})} })}
</View> </View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View> </View>
<Text style={styles.versionLabel}>{appVersionText}</Text> <Text style={styles.versionLabel}>{appVersionText}</Text>
+4
View File
@@ -19,6 +19,10 @@ export const MAX_CONTENT_WIDTH = 820;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78; export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45; export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in desktop app (any OS) // Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean { function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0 // Right sidebar: closed = +windowWidth (off-screen right), open = 0
const translateX = useSharedValue(isOpen ? 0 : windowWidth); const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const backdropOpacity = useSharedValue(isOpen ? 1 : 0); const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false); const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => { useEffect(() => {
// Skip if this is initial render or if we're mid-gesture const didStateChange = shouldSyncSidebarAnimation({
if (prevIsOpen.current === isOpen) { previousIsOpen: prevIsOpen.current,
return; nextIsOpen: isOpen,
} previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current; const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen; prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it // Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) { if (isGesturing.value) {
return; return;
} }
if (isOpen) { const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
translateX.value = withTiming(0, {
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
return;
} }
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [ }, [
isOpen, isOpen,
translateX, translateX,
backdropOpacity, backdropOpacity,
windowWidth, windowWidth,
isGesturing, isGesturing,
mobileView,
desktopFileExplorerOpen,
]); ]);
const animateToOpen = () => { const animateToOpen = () => {
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement"; import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!sessionAgents) { previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current.clear(); previousAgentStatusRef.current,
return; sessionAgents,
} );
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]); }, [sessionAgents]);
const hydrateWorkspaces = useCallback( const hydrateWorkspaces = useCallback(
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
function createAgent(status: Agent["status"]): Agent {
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status,
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
lastActivityAt: new Date(0),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent",
cwd: "/tmp",
model: null,
labels: {},
projectPlacement: null,
};
}
describe("reconcilePreviousAgentStatuses", () => {
it("preserves previously seen status for existing agents", () => {
const previous = new Map([["agent-1", "running" as const]]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("seeds newly seen agents from the current snapshot", () => {
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
expect(result).toEqual(new Map([["agent-1", "idle"]]));
});
it("removes agents that are no longer present", () => {
const previous = new Map([
["agent-1", "running" as const],
["agent-2", "idle" as const],
]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("clears all tracked statuses when the session is unavailable", () => {
const previous = new Map([["agent-1", "running" as const]]);
const result = reconcilePreviousAgentStatuses(previous, undefined);
expect(result).toEqual(new Map());
});
});
@@ -0,0 +1,29 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
sessionAgents: Map<string, Agent> | undefined,
): Map<string, AgentLifecycleStatus> {
if (!sessionAgents) {
return new Map();
}
const nextStatuses = new Map(previousStatuses);
const seenAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
seenAgentIds.add(agent.id);
if (!nextStatuses.has(agent.id)) {
nextStatuses.set(agent.id, agent.status);
}
}
for (const agentId of nextStatuses.keys()) {
if (!seenAgentIds.has(agentId)) {
nextStatuses.delete(agentId);
}
}
return nextStatuses;
}
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state // Initialize based on current state
const translateX = useSharedValue(isOpen ? 0 : -windowWidth); const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const backdropOpacity = useSharedValue(isOpen ? 1 : 0); const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false); const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => { useEffect(() => {
// Skip if this is initial render or if we're mid-gesture const didStateChange = shouldSyncSidebarAnimation({
if (prevIsOpen.current === isOpen) { previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return; return;
} }
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it // Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) { if (isGesturing.value) {
return; return;
} }
if (isOpen) { const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
translateX.value = withTiming(0, {
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
return;
} }
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
@@ -14,6 +14,7 @@ export function DesktopPermissionsSection() {
isRefreshing, isRefreshing,
requestingPermission, requestingPermission,
isSendingTestNotification, isSendingTestNotification,
testNotificationError,
refreshPermissions, refreshPermissions,
requestPermission, requestPermission,
sendTestNotification, sendTestNotification,
@@ -58,6 +59,11 @@ export function DesktopPermissionsSection() {
void sendTestNotification(); void sendTestNotification();
}} }}
/> />
{testNotificationError ? (
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
{testNotificationError}
</Text>
) : null}
<DesktopPermissionRow <DesktopPermissionRow
title="Microphone" title="Microphone"
showBorder showBorder
@@ -80,4 +86,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
}, },
errorText: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
})); }));
@@ -14,6 +14,7 @@ export interface UseDesktopPermissionsReturn {
isRefreshing: boolean; isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null; requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean; isSendingTestNotification: boolean;
testNotificationError: string | null;
refreshPermissions: () => Promise<void>; refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>; requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>; sendTestNotification: () => Promise<void>;
@@ -112,22 +113,25 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktop, refreshPermissions], [isDesktop, refreshPermissions],
); );
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => { const sendTestNotification = useCallback(async () => {
if (!isDesktop) { if (!isDesktop) {
return; return;
} }
setIsSendingTestNotification(true); setIsSendingTestNotification(true);
setTestNotificationError(null);
try { try {
const sent = await sendOsNotification({ const sent = await sendOsNotification({
title: "Paseo notification test", title: "Paseo notification test",
body: "If you can see this, desktop notifications work.", body: "If you can see this, desktop notifications work.",
}); });
if (!sent) { if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered"); setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
} }
} catch (error) { } catch (error) {
console.error("[Settings] Failed to send desktop test notification", error); setTestNotificationError("Failed to send notification.");
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsSendingTestNotification(false); setIsSendingTestNotification(false);
@@ -149,6 +153,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
isRefreshing, isRefreshing,
requestingPermission, requestingPermission,
isSendingTestNotification, isSendingTestNotification,
testNotificationError,
refreshPermissions, refreshPermissions,
requestPermission, requestPermission,
sendTestNotification, sendTestNotification,
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
new Set<string>(), new Set<string>(),
); );
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh"); expect(resolved.thinkingOptionId).toBe("xhigh");
}); });
it("keeps provider thinking preference when it is valid for the effective model", () => { it("prefers provider defaults on fresh drafts", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ { provider: "codex" },
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "low",
},
},
},
codexModels, codexModels,
{ {
serverId: false, serverId: false,
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
new Set<string>(), new Set<string>(),
); );
expect(resolved.thinkingOptionId).toBe("low"); expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
}); });
it("falls back to model default when saved thinking preference is invalid", () => { it("falls back to model default when saved thinking preference is invalid", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ { provider: "codex" },
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "medium",
},
},
},
codexModels, codexModels,
{ {
serverId: false, serverId: false,
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh"); expect(resolved.thinkingOptionId).toBe("xhigh");
}); });
it("normalizes legacy model id 'default' from initial values to auto", () => { it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
{ model: "default" }, { model: "default" },
{ provider: "codex" }, { provider: "codex" },
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
new Set<string>(), new Set<string>(),
); );
expect(resolved.model).toBe(""); expect(resolved.model).toBe("gpt-5.3-codex");
}); });
it("normalizes legacy model id 'default' from provider preferences to auto", () => { it("normalizes legacy model id 'default' to the provider default model", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, { model: "default" },
{ { provider: "codex" },
provider: "codex",
providerPreferences: {
codex: {
model: "default",
},
},
},
codexModels, codexModels,
{ {
serverId: false, serverId: false,
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
new Set<string>(), new Set<string>(),
); );
expect(resolved.model).toBe(""); expect(resolved.model).toBe("gpt-5.3-codex");
});
it("keeps an explicit initial thinking option when it is valid", () => {
const resolved = __private__.resolveFormState(
{ thinkingOptionId: "low" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
it("leaves thinking unset when the model exposes options without a provider default", () => {
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "default",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
];
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
claudeModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "claude",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
);
expect(resolved.model).toBe("default");
expect(resolved.thinkingOptionId).toBe("");
}); });
it("resolves provider only from allowed provider map", () => { it("resolves provider only from allowed provider map", () => {
+137 -103
View File
@@ -11,7 +11,11 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences"; import {
useFormPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
// Explicit overrides from URL params or "New Agent" button // Explicit overrides from URL params or "New Agent" button
export interface FormInitialValues { export interface FormInitialValues {
@@ -102,7 +106,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
function normalizeSelectedModelId(modelId: string | null | undefined): string { function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") { if (!normalized) {
return ""; return "";
} }
return normalized; return normalized;
@@ -117,6 +121,10 @@ function resolveDefaultModel(
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null; return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
} }
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
return resolveDefaultModel(availableModels)?.id ?? "";
}
function resolveEffectiveModel( function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null, availableModels: AgentModelDefinition[] | null,
modelId: string, modelId: string,
@@ -134,9 +142,31 @@ function resolveEffectiveModel(
); );
} }
function resolveThinkingOptionId(args: {
availableModels: AgentModelDefinition[] | null;
modelId: string;
requestedThinkingOptionId: string;
}): string {
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) {
return "";
}
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
if (
normalizedThinkingOptionId &&
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
) {
return normalizedThinkingOptionId;
}
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
}
/** /**
* Pure function that resolves form state from multiple data sources. * Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > preferences > provider defaults > fallback * Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
* *
* Only resolves fields that haven't been user-modified. * Only resolves fields that haven't been user-modified.
*/ */
@@ -195,25 +225,22 @@ function resolveFormState(
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false; const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model); const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) { if (initialModel) {
// If models aren't loaded yet, trust the initial value
// It will be validated once models load
if (!availableModels || isValidModel(initialModel)) { if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel; result.model = initialModel;
} else if (preferredModel && isValidModel(preferredModel)) {
result.model = preferredModel;
} else { } else {
result.model = ""; result.model = defaultModelId;
} }
} else if (preferredModel) { } else if (preferredModel) {
// If models haven't loaded yet, optimistically apply the stored preference.
// We'll validate once models load and clear it if it isn't available.
if (!availableModels || isValidModel(preferredModel)) { if (!availableModels || isValidModel(preferredModel)) {
result.model = preferredModel; result.model = preferredModel;
} else { } else {
result.model = ""; result.model = defaultModelId;
} }
} else if (defaultModelId) {
result.model = defaultModelId;
} else { } else {
result.model = ""; result.model = "";
} }
@@ -224,13 +251,17 @@ function resolveFormState(
typeof initialValues?.thinkingOptionId === "string" typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim() ? initialValues.thinkingOptionId.trim()
: ""; : "";
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
if (!userModified.thinkingOptionId) { if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
: "";
if (initialThinkingOptionId.length > 0) { if (initialThinkingOptionId.length > 0) {
result.thinkingOptionId = initialThinkingOptionId; result.thinkingOptionId = initialThinkingOptionId;
} else if (preferredThinkingOptionId.length > 0) { } else if (preferredThinking.length > 0) {
result.thinkingOptionId = preferredThinkingOptionId; result.thinkingOptionId = preferredThinking;
} else { } else {
result.thinkingOptionId = ""; result.thinkingOptionId = "";
} }
@@ -238,27 +269,17 @@ function resolveFormState(
// Validate thinking option once model metadata is available. // Validate thinking option once model metadata is available.
if (availableModels) { if (availableModels) {
const effectiveModel = resolveEffectiveModel(availableModels, result.model); result.thinkingOptionId = resolveThinkingOptionId({
const thinkingOptions = effectiveModel?.thinkingOptions ?? []; availableModels,
if (thinkingOptions.length === 0) { modelId: result.model,
result.thinkingOptionId = ""; requestedThinkingOptionId: result.thinkingOptionId,
} else { });
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
const defaultThinkingOptionId =
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
result.thinkingOptionId = defaultThinkingOptionId;
}
}
} }
// 5. Resolve serverId (independent) // 5. Resolve serverId (independent)
// Only use stored serverId if the host still exists in the registry
if (!userModified.serverId) { if (!userModified.serverId) {
if (initialValues?.serverId !== undefined) { if (initialValues?.serverId !== undefined) {
result.serverId = initialValues.serverId; result.serverId = initialValues.serverId;
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
result.serverId = preferences.serverId;
} }
// else keep current // else keep current
} }
@@ -267,8 +288,6 @@ function resolveFormState(
if (!userModified.workingDir) { if (!userModified.workingDir) {
if (initialValues?.workingDir !== undefined) { if (initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir; result.workingDir = initialValues.workingDir;
} else if (preferences?.workingDir) {
result.workingDir = preferences.workingDir;
} }
// else keep current (empty string) // else keep current (empty string)
} }
@@ -313,7 +332,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
preferences, preferences,
isLoading: isPreferencesLoading, isLoading: isPreferencesLoading,
updatePreferences, updatePreferences,
updateProviderPreferences,
} = useFormPreferences(); } = useFormPreferences();
const daemons = useHosts(); const daemons = useHosts();
@@ -537,107 +555,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
validServerIds, validServerIds,
]); ]);
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
useEffect(() => {
if (!isVisible || !isCreateFlow) return;
if (isPreferencesLoading) return;
if (userModified.serverId) return;
const serverId = formState.serverId;
if (!serverId) return;
if (preferences?.serverId === serverId) return;
void updatePreferences({ serverId });
}, [
isVisible,
isCreateFlow,
isPreferencesLoading,
userModified.serverId,
formState.serverId,
preferences?.serverId,
updatePreferences,
]);
// User setters - mark fields as modified and persist to preferences // User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback( const setSelectedServerIdFromUser = useCallback(
(value: string | null) => { (value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value })); setFormState((prev) => ({ ...prev, serverId: value }));
setUserModified((prev) => ({ ...prev, serverId: true })); setUserModified((prev) => ({ ...prev, serverId: true }));
void updatePreferences({ serverId: value ?? undefined });
}, },
[updatePreferences], [],
); );
const setProviderFromUser = useCallback( const setProviderFromUser = useCallback(
(provider: AgentProvider) => { (provider: AgentProvider) => {
setFormState((prev) => ({ ...prev, provider })); const providerModels = allProviderModels.get(provider) ?? null;
setUserModified((prev) => ({ ...prev, provider: true }));
void updatePreferences({ provider });
// When provider changes, reset mode and model to provider defaults
// (unless user has explicitly set them)
const providerDef = providerDefinitionMap.get(provider); const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider]; const providerPrefs = preferences?.providerPreferences?.[provider];
const isValidModel = (m: string) =>
providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
const nextModelId =
preferredModel && (!providerModels || isValidModel(preferredModel))
? preferredModel
: defaultModelId;
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
const nextModeId =
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
? providerPrefs.mode
: providerDef?.defaultModeId ?? "";
const preferredThinking = nextModelId
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
: "";
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: preferredThinking,
});
setUserModified((prev) => ({ ...prev, provider: true }));
void updatePreferences({ provider });
setFormState((prev) => ({ setFormState((prev) => ({
...prev, ...prev,
provider, provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", modeId: nextModeId,
model: normalizeSelectedModelId(providerPrefs?.model), model: nextModelId,
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", thinkingOptionId: nextThinkingOptionId,
})); }));
}, },
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences], [allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
); );
const setProviderAndModelFromUser = useCallback( const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => { (provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider); const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider]; const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: "",
});
setFormState((prev) => ({ setFormState((prev) => ({
...prev, ...prev,
provider, provider,
model: modelId, model: nextModelId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", modeId: providerDef?.defaultModeId ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", thinkingOptionId: nextThinkingOptionId,
})); }));
setUserModified((prev) => ({ ...prev, provider: true, model: true })); setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider }); void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
}, },
[ [allProviderModels, providerDefinitionMap, updatePreferences],
preferences?.providerPreferences,
providerDefinitionMap,
updatePreferences,
updateProviderPreferences,
],
); );
const setModeFromUser = useCallback( const setModeFromUser = useCallback(
(modeId: string) => { (modeId: string) => {
setFormState((prev) => ({ ...prev, modeId })); setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true })); setUserModified((prev) => ({ ...prev, modeId: true }));
void updateProviderPreferences(formState.provider, { mode: modeId });
}, },
[formState.provider, updateProviderPreferences], [],
); );
const setModelFromUser = useCallback( const setModelFromUser = useCallback(
(modelId: string) => { (modelId: string) => {
const normalizedModelId = normalizeSelectedModelId(modelId); const normalizedModelId = normalizeSelectedModelId(modelId);
setFormState((prev) => ({ ...prev, model: normalizedModelId })); const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: nextModelId,
requestedThinkingOptionId: userModified.thinkingOptionId
? formStateRef.current.thinkingOptionId
: "",
});
setFormState((prev) => ({
...prev,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, model: true })); setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
}, },
[formState.provider, updateProviderPreferences], [availableModels, userModified.thinkingOptionId],
); );
const setThinkingOptionFromUser = useCallback( const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => { (thinkingOptionId: string) => {
setFormState((prev) => ({ ...prev, thinkingOptionId })); setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
void updateProviderPreferences(formState.provider, { thinkingOptionId });
}, },
[formState.provider, updateProviderPreferences], [],
); );
const setWorkingDir = useCallback((value: string) => { const setWorkingDir = useCallback((value: string) => {
@@ -648,9 +678,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
(value: string) => { (value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value })); setFormState((prev) => ({ ...prev, workingDir: value }));
setUserModified((prev) => ({ ...prev, workingDir: true })); setUserModified((prev) => ({ ...prev, workingDir: true }));
void updatePreferences({ workingDir: value });
}, },
[updatePreferences], [],
); );
const setSelectedServerId = useCallback((value: string | null) => { const setSelectedServerId = useCallback((value: string | null) => {
@@ -662,39 +691,42 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [providerModelsQuery]); }, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
const providerPreferenceUpdates: { const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
mode: string; const modelId = resolvedModel?.id ?? formState.model;
model: string; const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
thinkingOptionId?: string;
} = { const nextProviderPrefs: ProviderPreferences = {
mode: formState.modeId, model: modelId || undefined,
model: formState.model, mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
}; };
if (userModified.thinkingOptionId) {
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
}
await updatePreferences({ await updatePreferences({
workingDir: formState.workingDir,
provider: formState.provider, provider: formState.provider,
serverId: formState.serverId ?? undefined, providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
}); });
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
}, [ }, [
formState.modeId, availableModels,
formState.model, formState.model,
formState.modeId,
formState.provider, formState.provider,
formState.serverId,
formState.thinkingOptionId, formState.thinkingOptionId,
formState.workingDir, preferences?.providerPreferences,
userModified.thinkingOptionId,
updatePreferences, updatePreferences,
updateProviderPreferences,
]); ]);
const agentDefinition = providerDefinitionMap.get(formState.provider); const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? []; const modeOptions = agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError = const modelError =
@@ -711,7 +743,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setProviderFromUser, setProviderFromUser,
selectedMode: formState.modeId, selectedMode: formState.modeId,
setModeFromUser, setModeFromUser,
selectedModel: formState.model, selectedModel: resolvedModelId,
setModelFromUser, setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId, selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser, setThinkingOptionFromUser,
@@ -737,7 +769,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
formState.serverId, formState.serverId,
formState.provider, formState.provider,
formState.modeId, formState.modeId,
formState.model, resolvedModelId,
formState.thinkingOptionId, formState.thinkingOptionId,
formState.workingDir, formState.workingDir,
setSelectedServerId, setSelectedServerId,
@@ -771,5 +803,7 @@ export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = { export const __private__ = {
combineInitialValues, combineInitialValues,
resolveDefaultModel,
resolveFormState, resolveFormState,
resolveThinkingOptionId,
}; };
@@ -1,5 +1,17 @@
import { useRef } from "react"; import { useRef } from "react";
import type { Agent } from "@/contexts/session-context";
export interface AgentScreenAgent {
serverId: string;
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
projectPlacement?: {
checkout?: {
cwd?: string;
isGit?: boolean;
};
} | null;
}
export type AgentScreenMissingState = export type AgentScreenMissingState =
| { kind: "idle" } | { kind: "idle" }
@@ -8,8 +20,8 @@ export type AgentScreenMissingState =
| { kind: "error"; message: string }; | { kind: "error"; message: string };
export interface AgentScreenMachineInput { export interface AgentScreenMachineInput {
agent: Agent | null; agent: AgentScreenAgent | null;
placeholderAgent: Agent | null; placeholderAgent: AgentScreenAgent | null;
missingAgentState: AgentScreenMissingState; missingAgentState: AgentScreenMissingState;
isConnected: boolean; isConnected: boolean;
isArchivingCurrentAgent: boolean; isArchivingCurrentAgent: boolean;
@@ -31,7 +43,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory { export interface AgentScreenMachineMemory {
hasRenderedReady: boolean; hasRenderedReady: boolean;
lastReadyAgent: Agent | null; lastReadyAgent: AgentScreenAgent | null;
activeToastLatch: AgentScreenToastLatch; activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean; hadInitialSyncFailure: boolean;
} }
@@ -70,7 +82,7 @@ export type AgentScreenViewState =
} }
| { | {
tag: "ready"; tag: "ready";
agent: Agent; agent: AgentScreenAgent;
source: "authoritative" | "optimistic" | "stale"; source: "authoritative" | "optimistic" | "stale";
sync: AgentScreenReadySyncState; sync: AgentScreenReadySyncState;
isArchiving: boolean; isArchiving: boolean;
+1 -39
View File
@@ -2,7 +2,6 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
@@ -10,13 +9,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
const providerPreferencesSchema = z.object({ const providerPreferencesSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
thinkingOptionId: z.string().optional(), thinkingByModel: z.record(z.string()).optional(),
}); });
const formPreferencesSchema = z.object({ const formPreferencesSchema = z.object({
workingDir: z.string().optional(),
provider: z.string().optional(), provider: z.string().optional(),
serverId: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(), providerPreferences: z.record(providerPreferencesSchema).optional(),
}); });
@@ -35,12 +32,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn { export interface UseFormPreferencesReturn {
preferences: FormPreferences; preferences: FormPreferences;
isLoading: boolean; isLoading: boolean;
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>; updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
updateProviderPreferences: (
provider: AgentProvider,
updates: Partial<ProviderPreferences>,
) => Promise<void>;
} }
export function useFormPreferences(): UseFormPreferencesReturn { export function useFormPreferences(): UseFormPreferencesReturn {
@@ -54,13 +46,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES; const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const getProviderPreferences = useCallback(
(provider: AgentProvider): ProviderPreferences | undefined => {
return preferences.providerPreferences?.[provider];
},
[preferences.providerPreferences],
);
const updatePreferences = useCallback( const updatePreferences = useCallback(
async (updates: Partial<FormPreferences>) => { async (updates: Partial<FormPreferences>) => {
const prev = const prev =
@@ -73,32 +58,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
[queryClient], [queryClient],
); );
const updateProviderPreferences = useCallback(
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next: FormPreferences = {
...prev,
providerPreferences: {
...prev.providerPreferences,
[provider]: {
...prev.providerPreferences?.[provider],
...updates,
},
},
};
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return { return {
preferences, preferences,
isLoading: isPending, isLoading: isPending,
getProviderPreferences,
updatePreferences, updatePreferences,
updateProviderPreferences,
}; };
} }
@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
expect(handle).toHaveBeenCalledTimes(1); expect(handle).toHaveBeenCalledTimes(1);
}); });
it("dispatches to the active mounted tab when a newer hidden tab is inactive", () => {
const calls: string[] = [];
const action: KeyboardActionDefinition = {
id: "message-input.dictation-toggle",
scope: "message-input",
};
dispatcher.registerHandler({
handlerId: "visible-tab",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle: () => {
calls.push("visible-tab");
return true;
},
});
dispatcher.registerHandler({
handlerId: "hidden-tab",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => false,
handle: () => {
calls.push("hidden-tab");
return true;
},
});
const handled = dispatcher.dispatch(action);
expect(handled).toBe(true);
expect(calls).toEqual(["visible-tab"]);
});
it("tries lower-priority handlers when a higher one does not consume the action", () => { it("tries lower-priority handlers when a higher one does not consume the action", () => {
const calls: string[] = []; const calls: string[] = [];
const action: KeyboardActionDefinition = { const action: KeyboardActionDefinition = {
+62 -84
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native"; import { ActivityIndicator, Platform, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Bot } from "lucide-react-native"; import { Bot } from "lucide-react-native";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
@@ -16,6 +18,7 @@ import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { import {
useAgentScreenStateMachine, useAgentScreenStateMachine,
type AgentScreenAgent,
type AgentScreenMissingState, type AgentScreenMissingState,
} from "@/hooks/use-agent-screen-state-machine"; } from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -78,11 +81,21 @@ function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string }, target: { kind: "agent"; agentId: string },
context: { serverId: string }, context: { serverId: string },
): PanelDescriptor { ): PanelDescriptor {
const agent = useSessionStore( const descriptorState = useSessionStore(
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null, useShallow((state) => {
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
};
}),
); );
const provider = agent?.provider ?? "codex"; const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(agent?.title); const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot; const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
return { return {
@@ -90,12 +103,12 @@ function useAgentPanelDescriptor(
subtitle: `${formatProviderLabel(provider)} agent`, subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading", titleState: label ? "ready" : "loading",
icon, icon,
statusBucket: agent statusBucket: descriptorState.status
? deriveSidebarStateBucket({ ? deriveSidebarStateBucket({
status: agent.status, status: descriptorState.status,
pendingPermissionCount: agent.pendingPermissions.length, pendingPermissionCount: descriptorState.pendingPermissionCount,
requiresAttention: agent.requiresAttention, requiresAttention: descriptorState.requiresAttention,
attentionReason: agent.attentionReason, attentionReason: descriptorState.attentionReason,
}) })
: null, : null,
}; };
@@ -243,8 +256,25 @@ function AgentPanelBody({
addImagesRef.current = addImages; addImagesRef.current = addImages;
}, []); }, []);
const agent = useSessionStore((state) => const agentState = useSessionStore(
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined, useShallow((state) => {
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
archivedAt: agent?.archivedAt ?? null,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
};
}),
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const streamItemsRaw = useSessionStore((state) => const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
@@ -318,52 +348,14 @@ function AgentPanelBody({
agentId, agentId,
client, client,
isConnected, isConnected,
requiresAttention: agent?.requiresAttention, requiresAttention: agentState.requiresAttention,
attentionReason: agent?.attentionReason, attentionReason: agentState.attentionReason,
isScreenFocused: isPaneFocused, isScreenFocused: isPaneFocused,
}); });
useEffect(() => { useEffect(() => {
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]); }, [attentionController.clearOnAgentBlur]);
// ---------------------------------------------------------------------------
// DEBUG: track which selector values change between renders
// ---------------------------------------------------------------------------
const debugPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugPrevRef.current;
const curr: Record<string, unknown> = {
agent,
"agent?.status": agent?.status,
"agent?.cwd": agent?.cwd,
"agent?.updatedAt": agent?.updatedAt,
"agent?.requiresAttention": agent?.requiresAttention,
streamItemsRaw,
"streamItems.length": streamItems.length,
allPendingPermissions,
isInitializingFromMap,
historySyncGeneration,
hasAppliedAuthoritativeHistory,
agentHistorySyncGeneration,
hasSession,
isPaneFocused,
isConnected,
};
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("[AgentPanelBody] values changed:", changed.join(", "), {
changed: Object.fromEntries(changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])),
});
}
debugPrevRef.current = curr;
});
// ---------------------------------------------------------------------------
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate", mode: "translate",
}); });
@@ -488,49 +480,34 @@ function AgentPanelBody({
}, [optimisticStreamItems, streamItems]); }, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0; const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
const authoritativeStatus = agent?.status; const authoritativeStatus = agentState.status;
const isAuthoritativeBootstrapping = const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle"; authoritativeStatus === "initializing" || authoritativeStatus === "idle";
const showPendingCreateSubmitLoading = const showPendingCreateSubmitLoading =
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping); isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
const placeholderAgent: Agent | null = useMemo(() => { const agent: AgentScreenAgent | null =
agentState.serverId && agentState.id && agentState.status && agentState.cwd
? {
serverId: agentState.serverId,
id: agentState.id,
status: agentState.status,
cwd: agentState.cwd,
projectPlacement,
}
: null;
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
if (!shouldUseOptimisticStream || !agentId) { if (!shouldUseOptimisticStream || !agentId) {
return null; return null;
} }
const now = new Date();
return { return {
serverId, serverId,
id: agentId, id: agentId,
provider: "claude",
status: "running", status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "claude",
sessionId: null,
model: null,
modeId: null,
},
title: "Agent",
cwd: ".", cwd: ".",
model: null, projectPlacement: null,
labels: {},
}; };
}, [agentId, serverId, shouldUseOptimisticStream]); }, [agentId, serverId, shouldUseOptimisticStream]);
@@ -642,7 +619,7 @@ function AgentPanelBody({
if (!agentId) { if (!agentId) {
return; return;
} }
if (agent || shouldUseOptimisticStream) { if (agentState.id || shouldUseOptimisticStream) {
if (missingAgentState.kind !== "idle") { if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" }); setMissingAgentState({ kind: "idle" });
} }
@@ -717,7 +694,7 @@ function AgentPanelBody({
setMissingAgentState({ kind: "error", message }); setMissingAgentState({ kind: "error", message });
}); });
}, [ }, [
agent, agentState.id,
agentId, agentId,
client, client,
ensureAgentIsInitialized, ensureAgentIsInitialized,
@@ -803,10 +780,11 @@ function AgentPanelBody({
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</View> </View>
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? ( {agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<AgentInputArea <AgentInputArea
agentId={agentId} agentId={agentId}
serverId={serverId} serverId={serverId}
isInputActive={isPaneFocused}
value={agentInputDraft.text} value={agentInputDraft.text}
onChangeText={agentInputDraft.setText} onChangeText={agentInputDraft.setText}
images={agentInputDraft.images} images={agentInputDraft.images}
@@ -831,7 +809,7 @@ function AgentPanelBody({
streamViewRef.current?.scrollToBottom("message-sent"); streamViewRef.current?.scrollToBottom("message-sent");
}} }}
/> />
) : agentId && agent?.archivedAt ? ( ) : agentId && agentState.archivedAt ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} /> <ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null} ) : null}
+10 -2
View File
@@ -17,8 +17,15 @@ function useDraftPanelDescriptor() {
} }
function DraftPanel() { function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = const {
usePaneContext(); serverId,
workspaceId,
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target"); invariant(target.kind === "draft", "DraftPanel requires draft target");
return ( return (
@@ -27,6 +34,7 @@ function DraftPanel() {
workspaceId={workspaceId} workspaceId={workspaceId}
tabId={tabId} tabId={tabId}
draftId={target.draftId} draftId={target.draftId}
isPaneFocused={isPaneFocused}
onOpenWorkspaceFile={({ filePath }) => { onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath); openFileInWorkspace(filePath);
}} }}
+32 -18
View File
@@ -1069,7 +1069,8 @@ export class HostRuntimeController {
} }
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767"; const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1"; const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const E2E_STORAGE_KEY = "@paseo:e2e"; const E2E_STORAGE_KEY = "@paseo:e2e";
@@ -1136,7 +1137,7 @@ export class HostRuntimeStore {
} }
} }
async bootstrap(): Promise<void> { async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
if (this.bootstrapAttempted) { if (this.bootstrapAttempted) {
return; return;
} }
@@ -1152,30 +1153,43 @@ export class HostRuntimeStore {
} }
if (shouldUseDesktopDaemon()) { if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop(); if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
} else { } else {
await this.bootstrapLocalhost(); await this.bootstrapLocalhost();
} }
} }
private async bootstrapDesktop(): Promise<void> { private async bootstrapDesktop(): Promise<void> {
try { let lastError: unknown = null;
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen); for (let attempt = 1; attempt <= 3; attempt += 1) {
if (!connection) { try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection || !daemon.serverId) {
return;
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
return; return;
} catch (error) {
lastError = error;
console.warn(`[HostRuntime] Failed to bootstrap desktop daemon (attempt ${attempt}/3)`, error);
if (attempt < 3) {
await new Promise((resolve) => {
setTimeout(resolve, attempt * 500);
});
}
} }
const { client, serverId, hostname } = await connectToDaemon(connection, { }
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
}); if (lastError) {
await this.upsertHostConnection({ console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError);
serverId,
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
} }
} }
@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]); }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
const createAgentClient = sessionClient; const createAgentClient = sessionClient;
const effectiveDraftModelId = useMemo(() => {
if (selectedModel.trim()) {
return selectedModel.trim();
}
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
}, [availableModels, selectedModel]);
const effectiveDraftThinkingOptionId = useMemo(() => {
if (selectedThinkingOptionId.trim()) {
return selectedThinkingOptionId.trim();
}
const selectedModelDefinition =
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => { const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = ( const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
provider: selectedProvider, provider: selectedProvider,
cwd, cwd,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(selectedThinkingOptionId.trim() ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: selectedThinkingOptionId.trim() } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
}; };
}, [ }, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
isAttachWorktree, isAttachWorktree,
modeOptions.length, modeOptions.length,
selectedMode, selectedMode,
selectedModel,
selectedProvider, selectedProvider,
selectedThinkingOptionId,
selectedWorktreePath, selectedWorktreePath,
workingDir, workingDir,
]); ]);
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
if (gitBlockingError) { if (gitBlockingError) {
return gitBlockingError; return gitBlockingError;
} }
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveDraftModelId) {
return "No model is available for the selected provider";
}
if (isAttachWorktree && !selectedWorktreePath) { if (isAttachWorktree && !selectedWorktreePath) {
return "Select a worktree to attach"; return "Select a worktree to attach";
} }
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
"."; ".";
const provider = selectedProvider; const provider = selectedProvider;
const model = selectedModel.trim() || null; const model = effectiveDraftModelId || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null; const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return { return {
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: selectedProvider, provider: selectedProvider,
cwd: resolvedWorkingDir, cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}), ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
}; };
const effectiveBaseBranch = baseBranch.trim(); const effectiveBaseBranch = baseBranch.trim();
@@ -1199,6 +1219,7 @@ function DraftAgentScreenContent({
<AgentInputArea <AgentInputArea
agentId={draftAgentIdRef.current} agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""} serverId={selectedServerId ?? ""}
isInputActive={isFocused}
onSubmitMessage={handleCreateFromInput} onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
@@ -7,7 +7,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) { export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -19,8 +18,10 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openProjectPicker = useOpenProjectPicker(serverId); const openProjectPicker = useOpenProjectPicker(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac(); const collapsedSidebarInset =
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0; !isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
useEffect(() => { useEffect(() => {
@@ -31,7 +32,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return ( return (
<View style={styles.container} {...dragHandlers}> <View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}> <View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: collapsedSidebarInset.left, paddingRight: collapsedSidebarInset.right }]}>
<SidebarMenuToggle /> <SidebarMenuToggle />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => { (newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme }); void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
}, },
[updateSettings], [updateSettings],
); );
@@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentSessionConfig, AgentSessionConfig,
@@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = {
workspaceId: string; workspaceId: string;
tabId: string; tabId: string;
draftId: string; draftId: string;
isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void; onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string }) => void; onOpenWorkspaceFile: (input: { filePath: string }) => void;
}; };
@@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({
workspaceId, workspaceId,
tabId, tabId,
draftId, draftId,
isPaneFocused,
onCreated, onCreated,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: WorkspaceDraftAgentTabProps) { }: WorkspaceDraftAgentTabProps) {
@@ -92,6 +95,22 @@ export function WorkspaceDraftAgentTab({
setWorkingDir(workspaceId); setWorkingDir(workspaceId);
}, [setWorkingDir, workingDir, workspaceId]); }, [setWorkingDir, workingDir, workspaceId]);
const effectiveDraftModelId = useMemo(() => {
if (selectedModel.trim()) {
return selectedModel.trim();
}
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
}, [availableModels, selectedModel]);
const effectiveDraftThinkingOptionId = useMemo(() => {
if (selectedThinkingOptionId.trim()) {
return selectedThinkingOptionId.trim();
}
const selectedModelDefinition =
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const { const {
formErrorMessage, formErrorMessage,
isSubmitting, isSubmitting,
@@ -108,6 +127,12 @@ export function WorkspaceDraftAgentTab({
if (providerDefinitions.length === 0) { if (providerDefinitions.length === 0) {
return "No available providers on the selected host"; return "No available providers on the selected host";
} }
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveDraftModelId) {
return "No model is available for the selected provider";
}
if (!client) { if (!client) {
return "Host is not connected"; return "Host is not connected";
} }
@@ -122,8 +147,8 @@ export function WorkspaceDraftAgentTab({
}, },
buildDraftAgent: (attempt) => { buildDraftAgent: (attempt) => {
const now = attempt.timestamp; const now = attempt.timestamp;
const model = selectedModel.trim() || null; const model = effectiveDraftModelId || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null; const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return { return {
serverId, serverId,
@@ -153,14 +178,14 @@ export function WorkspaceDraftAgentTab({
} }
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: selectedProvider, provider: selectedProvider,
cwd: workspaceId, cwd: workspaceId,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}), ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
}; };
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
@@ -186,17 +211,17 @@ export function WorkspaceDraftAgentTab({
provider: selectedProvider, provider: selectedProvider,
cwd: workspaceId, cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(selectedThinkingOptionId.trim() ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: selectedThinkingOptionId.trim() } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
}; };
}, [ }, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
modeOptions.length, modeOptions.length,
selectedMode, selectedMode,
selectedModel,
selectedProvider, selectedProvider,
selectedThinkingOptionId,
workspaceId, workspaceId,
]); ]);
@@ -243,6 +268,7 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea <AgentInputArea
agentId={tabId} agentId={tabId}
serverId={serverId} serverId={serverId}
isInputActive={isPaneFocused}
onSubmitMessage={handleCreateFromInput} onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
@@ -251,7 +277,7 @@ export function WorkspaceDraftAgentTab({
images={draftInput.images} images={draftInput.images}
onChangeImages={draftInput.setImages} onChangeImages={draftInput.setImages}
clearDraft={draftInput.clear} clearDraft={draftInput.clear}
autoFocus={!isSubmitting} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig} commandDraftConfig={draftCommandConfig}
statusControls={{ statusControls={{
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus";
describe("shouldAutoFocusWorkspaceDraftComposer", () => {
it("focuses the draft composer when the pane is focused and idle", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: true,
isSubmitting: false,
}),
).toBe(true);
});
it("does not focus the draft composer when the pane is unfocused", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: false,
isSubmitting: false,
}),
).toBe(false);
});
it("does not focus the draft composer while the draft is submitting", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: true,
isSubmitting: true,
}),
).toBe(false);
});
});
@@ -0,0 +1,6 @@
export function shouldAutoFocusWorkspaceDraftComposer(input: {
isPaneFocused: boolean;
isSubmitting: boolean;
}): boolean {
return input.isPaneFocused && !input.isSubmitting;
}
@@ -37,4 +37,36 @@ describe("createMarkdownStyles", () => {
overflowWrap: "anywhere", overflowWrap: "anywhere",
}); });
}); });
it("keeps assistant markdown text selectable on web", () => {
const styles = createMarkdownStyles(darkTheme);
expect(styles.body).toMatchObject({
userSelect: "text",
});
expect(styles.text).toMatchObject({
userSelect: "text",
});
expect(styles.heading1).toMatchObject({
userSelect: "text",
});
expect(styles.link).toMatchObject({
userSelect: "text",
});
expect(styles.code_inline).toMatchObject({
userSelect: "text",
});
expect(styles.code_block).toMatchObject({
userSelect: "text",
});
expect(styles.fence).toMatchObject({
userSelect: "text",
});
expect(styles.bullet_list_icon).toMatchObject({
userSelect: "text",
});
expect(styles.ordered_list_icon).toMatchObject({
userSelect: "text",
});
});
}); });
@@ -1,6 +1,9 @@
import { Platform } from "react-native";
import type { Theme } from "./theme"; import type { Theme } from "./theme";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
/** /**
* Creates comprehensive markdown styles for react-native-markdown-display. * Creates comprehensive markdown styles for react-native-markdown-display.
* *
@@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) {
// ========================================================================= // =========================================================================
body: { body: {
...webSelectableTextStyle,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
lineHeight: 22, lineHeight: 22,
@@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
text: { text: {
...webSelectableTextStyle,
color: theme.colors.foreground, color: theme.colors.foreground,
flexShrink: 1, flexShrink: 1,
minWidth: 0, minWidth: 0,
@@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) {
// ========================================================================= // =========================================================================
heading1: { heading1: {
...webSelectableTextStyle,
fontSize: theme.fontSize["3xl"], fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
heading2: { heading2: {
...webSelectableTextStyle,
fontSize: theme.fontSize["2xl"], fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
heading3: { heading3: {
...webSelectableTextStyle,
fontSize: theme.fontSize.xl, fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
heading4: { heading4: {
...webSelectableTextStyle,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
heading5: { heading5: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
heading6: { heading6: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) {
// ========================================================================= // =========================================================================
strong: { strong: {
...webSelectableTextStyle,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
em: { em: {
...webSelectableTextStyle,
fontStyle: "italic" as const, fontStyle: "italic" as const,
}, },
s: { s: {
...webSelectableTextStyle,
textDecorationLine: "line-through" as const, textDecorationLine: "line-through" as const,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
link: { link: {
...webSelectableTextStyle,
color: theme.colors.accentBright, color: theme.colors.accentBright,
textDecorationLine: "none" as const, textDecorationLine: "none" as const,
flexShrink: 1, flexShrink: 1,
@@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
blocklink: { blocklink: {
...webSelectableTextStyle,
color: theme.colors.accentBright, color: theme.colors.accentBright,
textDecorationLine: "none" as const, textDecorationLine: "none" as const,
flexShrink: 1, flexShrink: 1,
@@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) {
// ========================================================================= // =========================================================================
code_inline: { code_inline: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
color: theme.colors.foreground, color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
@@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
code_block: { code_block: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
color: theme.colors.foreground, color: theme.colors.foreground,
padding: theme.spacing[3], padding: theme.spacing[3],
@@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
fence: { fence: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
color: theme.colors.foreground, color: theme.colors.foreground,
padding: theme.spacing[3], padding: theme.spacing[3],
@@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) {
tbody: {}, tbody: {},
th: { th: {
...webSelectableTextStyle,
padding: theme.spacing[2], padding: theme.spacing[2],
borderBottomWidth: 1, borderBottomWidth: 1,
borderRightWidth: 1, borderRightWidth: 1,
@@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
td: { td: {
...webSelectableTextStyle,
padding: theme.spacing[2], padding: theme.spacing[2],
borderRightWidth: 1, borderRightWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
bullet_list_icon: { bullet_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
marginRight: 4, marginRight: 4,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) {
}, },
ordered_list_icon: { ordered_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
marginRight: 4, marginRight: 4,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
+20 -6
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native"; import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import { import {
getIsDesktopMac, getIsDesktopMac,
getIsDesktop,
DESKTOP_TRAFFIC_LIGHT_WIDTH, DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT, DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout"; } from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window"; import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host"; import { isDesktop } from "@/desktop/host";
@@ -120,11 +123,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]); }, [isActive]);
} }
export function useTrafficLightPadding(): { left: number; top: number } { export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return; if (Platform.OS !== "web" || !getIsDesktop()) return;
let disposed = false; let disposed = false;
let cleanup: (() => void) | undefined; let cleanup: (() => void) | undefined;
@@ -175,12 +178,23 @@ export function useTrafficLightPadding(): { left: number; top: number } {
}; };
}, []); }, []);
if (!getIsDesktopMac() || isFullscreen) { if (!getIsDesktop() || isFullscreen) {
return { left: 0, top: 0 }; return { left: 0, right: 0, top: 0, side: null };
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
side: 'left',
};
} }
return { return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH, left: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
}; };
} }
@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex"); expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
}); });
it("treats legacy 'default' model ids as unset", () => { it("preserves 'default' as a valid model id", () => {
const agent = { const agent = {
model: "default", model: "default",
runtimeInfo: { model: "default" }, runtimeInfo: { model: "default" },
} as Partial<Agent> as Agent; } as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBeNull(); expect(extractAgentModel(agent)).toBe("default");
}); });
}); });
@@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null {
const fallbackModel = agent.model; const fallbackModel = agent.model;
if (typeof runtimeModel === "string") { if (typeof runtimeModel === "string") {
const normalized = runtimeModel.trim(); const normalized = runtimeModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") { if (normalized.length > 0) {
return normalized; return normalized;
} }
} }
if (typeof fallbackModel === "string") { if (typeof fallbackModel === "string") {
const normalized = fallbackModel.trim(); const normalized = fallbackModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") { if (normalized.length > 0) {
return normalized; return normalized;
} }
} }
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});
@@ -0,0 +1,41 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { focusWithRetries } from "./web-focus";
describe("focusWithRetries", () => {
let frameQueue: FrameRequestCallback[] = [];
let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined;
beforeEach(() => {
frameQueue = [];
originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
frameQueue.push(callback);
return frameQueue.length;
}) as typeof requestAnimationFrame;
});
afterEach(() => {
if (originalRequestAnimationFrame) {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
return;
}
delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame })
.requestAnimationFrame;
});
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = frameQueue;
frameQueue = [];
for (const callback of callbacks) {
callback(index);
}
}
}
it("tries to focus immediately before waiting for animation frames", () => {
let focused = false;
const focus = vi.fn(() => {
focused = true;
});
const onSuccess = vi.fn();
focusWithRetries({
focus,
isFocused: () => focused,
onSuccess,
});
expect(focus).toHaveBeenCalledTimes(1);
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(frameQueue).toHaveLength(0);
});
it("keeps retrying on later animation frames until focus succeeds", () => {
let focused = false;
let attempts = 0;
const focus = vi.fn(() => {
attempts += 1;
if (attempts >= 3) {
focused = true;
}
});
const onSuccess = vi.fn();
focusWithRetries({
focus,
isFocused: () => focused,
onSuccess,
});
expect(focus).toHaveBeenCalledTimes(1);
expect(onSuccess).not.toHaveBeenCalled();
flushAnimationFrames(2);
expect(focus).toHaveBeenCalledTimes(2);
expect(onSuccess).not.toHaveBeenCalled();
flushAnimationFrames(2);
expect(focus).toHaveBeenCalledTimes(3);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("stops retrying after cancellation", () => {
const focus = vi.fn();
const cancel = focusWithRetries({
focus,
isFocused: () => false,
});
expect(focus).toHaveBeenCalledTimes(1);
cancel();
flushAnimationFrames(4);
expect(focus).toHaveBeenCalledTimes(1);
});
});
+1 -3
View File
@@ -40,9 +40,7 @@ export function focusWithRetries({
}); });
}; };
requestAnimationFrame(() => { tick();
requestAnimationFrame(tick);
});
return () => { return () => {
cancelled = true; cancelled = true;
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.32", "version": "0.1.37",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.32", "@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+5
View File
@@ -17,6 +17,7 @@ import { addStopOptions, runStopCommand } from "./commands/agent/stop.js";
import { addSendOptions, runSendCommand } from "./commands/agent/send.js"; import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js"; import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js"; import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.js";
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js"; import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
import { withOutput } from "./output/index.js"; import { withOutput } from "./output/index.js";
import { onboardCommand } from "./commands/onboard.js"; import { onboardCommand } from "./commands/onboard.js";
@@ -93,6 +94,10 @@ export function createCli(): Command {
addWaitOptions(program.command("wait")), addWaitOptions(program.command("wait")),
).action(withOutput(runWaitCommand)); ).action(withOutput(runWaitCommand));
addJsonAndDaemonHostOptions(
addArchiveOptions(program.command("archive")),
).action(withOutput(runArchiveCommand));
// Top-level local daemon shortcuts // Top-level local daemon shortcuts
program.addCommand(onboardCommand()); program.addCommand(onboardCommand());
program.addCommand(daemonStartCommand()); program.addCommand(daemonStartCommand());
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Command } from "commander"; import { Command } from "commander";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type { import type {
CommandOptions, CommandOptions,
@@ -24,6 +24,13 @@ export const archiveSchema: OutputSchema<AgentArchiveResult> = {
], ],
}; };
export function addArchiveOptions(cmd: Command): Command {
return cmd
.description('Archive an agent (soft-delete)')
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)");
}
export interface AgentArchiveOptions extends CommandOptions { export interface AgentArchiveOptions extends CommandOptions {
force?: boolean; force?: boolean;
host?: string; host?: string;
+2 -4
View File
@@ -1,5 +1,6 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command { export function addDeleteOptions(cmd: Command): Command {
return cmd return cmd
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
if (options.all) { if (options.all) {
agents = agents.filter((a) => !a.archivedAt); agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) { } else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => { agents = agents.filter((a) => {
if (a.archivedAt) return false; if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, ""); return isSameOrDescendantPath(options.cwd!, a.cwd);
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
}); });
} else if (id) { } else if (id) {
const fetchResult = await client.fetchAgent(id); const fetchResult = await client.fetchAgent(id);
+2 -6
View File
@@ -1,6 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { runModeCommand } from "./mode.js"; import { runModeCommand } from "./mode.js";
import { runArchiveCommand } from "./archive.js"; import { addArchiveOptions, runArchiveCommand } from "./archive.js";
import { addDeleteOptions, runDeleteCommand } from "./delete.js"; import { addDeleteOptions, runDeleteCommand } from "./delete.js";
import { addLsOptions, runLsCommand } from "./ls.js"; import { addLsOptions, runLsCommand } from "./ls.js";
import { addRunOptions, runRunCommand } from "./run.js"; import { addRunOptions, runRunCommand } from "./run.js";
@@ -69,11 +69,7 @@ export function createAgentCommand(): Command {
).action(withOutput(runModeCommand)); ).action(withOutput(runModeCommand));
addJsonAndDaemonHostOptions( addJsonAndDaemonHostOptions(
agent addArchiveOptions(agent.command("archive")),
.command("archive")
.description("Archive an agent (soft-delete)")
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)"),
).action(withOutput(runArchiveCommand)); ).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions( addJsonAndDaemonHostOptions(
+2 -5
View File
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js"; import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command { export function addLsOptions(cmd: Command): Command {
return cmd return cmd
@@ -193,11 +194,7 @@ export async function runLsCommand(
// Optional cwd filter. // Optional cwd filter.
if (options.cwd) { if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, ""); agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} }
// Apply label filtering only when explicitly requested. // Apply label filtering only when explicitly requested.
+2 -2
View File
@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
}; };
export interface AgentSendOptions extends CommandOptions { export interface AgentSendOptions extends CommandOptions {
noWait?: boolean; wait?: boolean;
image?: string[]; image?: string[];
prompt?: string; prompt?: string;
promptFile?: string; promptFile?: string;
@@ -193,7 +193,7 @@ export async function runSendCommand(
await client.sendAgentMessage(agentIdArg, promptInput, { images }); await client.sendAgentMessage(agentIdArg, promptInput, { images });
// If --no-wait, return immediately // If --no-wait, return immediately
if (options.noWait) { if (options.wait === false) {
await client.close(); await client.close();
return { return {
+2 -4
View File
@@ -1,5 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type { import type {
CommandOptions, CommandOptions,
SingleResult, SingleResult,
@@ -75,12 +76,9 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt); agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) { } else if (options.cwd) {
// Stop agents in directory // Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => { agents = agents.filter((a) => {
if (a.archivedAt) return false; if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, ""); return isSameOrDescendantPath(options.cwd!, a.cwd);
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
}); });
} else if (id) { } else if (id) {
// Stop specific agent // Stop specific agent
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") || normalized.startsWith("/") ||
normalized.startsWith("unix://") || normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") || normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) { ) {
return null; return null;
} }
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { platform } from "node:os";
export interface NodePathFromPidResult { export interface NodePathFromPidResult {
nodePath: string | null; nodePath: string | null;
@@ -12,17 +13,14 @@ function normalizeError(error: unknown): string {
return String(error); return String(error);
} }
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
if (result.error) { if (result.error) {
return { return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
};
} }
if ((result.status ?? 1) !== 0) { if ((result.status ?? 1) !== 0) {
@@ -34,12 +32,36 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
} }
const resolved = result.stdout.trim(); const resolved = result.stdout.trim();
if (!resolved) { return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const result = spawnSync(
"wmic",
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
if (result.error) {
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return { return {
nodePath: null, nodePath: null,
error: "ps returned an empty command path", error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
}; };
} }
return { nodePath: resolved }; // wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
const match = result.stdout.match(/ExecutablePath=(.+)/);
const resolved = match?.[1]?.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
return platform() === "win32"
? resolveNodePathFromPidWindows(pid)
: resolveNodePathFromPidUnix(pid);
} }
@@ -1,3 +1,4 @@
import path from "path";
import type { Command } from "commander"; import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { import type {
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
// Find the worktree by name or branch // Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => { const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split("/").pop(); const name = path.basename(wt.worktreePath);
return name === nameArg || wt.branchName === nameArg; return name === nameArg || wt.branchName === nameArg;
}); });
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
throw error; throw error;
} }
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg; const worktreeName = path.basename(worktree.worktreePath) || nameArg;
return { return {
type: "single", type: "single",
+6 -1
View File
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed; return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
} }
if (path.isAbsolute(trimmed)) { if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
return `unix://${trimmed}`; return `unix://${trimmed}`;
} }
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
return null;
}
if (/^\d+$/.test(trimmed)) { if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`; return `127.0.0.1:${trimmed}`;
} }
+27
View File
@@ -0,0 +1,27 @@
/**
* Path utilities for cwd filtering in agent commands.
*/
/**
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
*
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
* This is important because agent cwd paths come from the agent's OS (could be Windows)
* while the CLI filter path comes from the user (could also be Windows or Unix).
*/
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase ||
normalizedCandidate.startsWith(normalizedBase + "/")
);
}
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
console.log("✓ rejects empty and non-host listen values\n"); console.log("✓ rejects empty and non-host listen values\n");
} }
{
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
console.log("✓ rejects Windows absolute paths\n");
}
console.log("=== All local daemon utility tests passed ==="); console.log("=== All local daemon utility tests passed ===");
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n"); console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
} }
{
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
}
{ {
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback"); console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-")); const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
@@ -0,0 +1,213 @@
#!/usr/bin/env npx tsx
/**
* CWD Filter Path Tests
*
* Tests that cwd filtering in agent commands works correctly
* with both Unix and Windows-style paths.
*
* Bug: The original code used hardcoded "/" separators for:
* 1. Stripping trailing separators (only stripped "/", not "\")
* 2. Descendant matching (appended "/" instead of platform separator)
*
* This causes Windows paths like "C:\Users\dev\project" to fail matching
* against "C:\Users\dev\project\sub" because the code appends "/" instead
* of "\" for the startsWith check.
*/
import assert from "node:assert";
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
console.log("=== CWD Filter Path Tests ===\n");
// Test 1: Unix exact match
{
console.log("Test 1: Unix exact match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
true,
"exact Unix paths should match",
);
console.log("✓ Unix exact match\n");
}
// Test 2: Unix descendant match
{
console.log("Test 2: Unix descendant match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
true,
"Unix descendant should match",
);
console.log("✓ Unix descendant match\n");
}
// Test 3: Unix non-match (sibling)
{
console.log("Test 3: Unix non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
false,
"sibling directories should not match",
);
console.log("✓ Unix non-match (sibling)\n");
}
// Test 4: Unix prefix overlap (project vs project2)
{
console.log("Test 4: Unix prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
false,
"prefix overlap without separator should not match",
);
console.log("✓ Unix prefix overlap\n");
}
// Test 5: Unix trailing slash on base
{
console.log("Test 5: Unix trailing slash on base");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
true,
"trailing slash on base should still match descendants",
);
console.log("✓ Unix trailing slash on base\n");
}
// Test 6: Unix trailing slash on candidate
{
console.log("Test 6: Unix trailing slash on candidate");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
true,
"trailing slash on candidate should match as same dir",
);
console.log("✓ Unix trailing slash on candidate\n");
}
// Test 7: Windows exact match
{
console.log("Test 7: Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
true,
"exact Windows paths should match",
);
console.log("✓ Windows exact match\n");
}
// Test 8: Windows descendant match
{
console.log("Test 8: Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
true,
"Windows descendant should match",
);
console.log("✓ Windows descendant match\n");
}
// Test 9: Windows non-match (sibling)
{
console.log("Test 9: Windows non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
false,
"Windows sibling directories should not match",
);
console.log("✓ Windows non-match (sibling)\n");
}
// Test 10: Windows prefix overlap (project vs project2)
{
console.log("Test 10: Windows prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
false,
"Windows prefix overlap without separator should not match",
);
console.log("✓ Windows prefix overlap\n");
}
// Test 11: Windows trailing backslash on base
{
console.log("Test 11: Windows trailing backslash on base");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
true,
"trailing backslash on base should still match descendants",
);
console.log("✓ Windows trailing backslash on base\n");
}
// Test 12: Windows trailing backslash on candidate
{
console.log("Test 12: Windows trailing backslash on candidate");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
true,
"trailing backslash on candidate should match as same dir",
);
console.log("✓ Windows trailing backslash on candidate\n");
}
// Test 13: Mixed separators (agent might use \ while CLI sends /)
{
console.log("Test 13: Mixed separators");
assert.strictEqual(
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
true,
"mixed separators should still match",
);
console.log("✓ Mixed separators\n");
}
// Test 14: Deep Windows descendant
{
console.log("Test 14: Deep Windows descendant");
assert.strictEqual(
isSameOrDescendantPath(
"C:\\Users\\dev\\project",
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
),
true,
"deep Windows descendant should match",
);
console.log("✓ Deep Windows descendant\n");
}
// Test 15: Case-insensitive Windows descendant match
{
console.log("Test 15: Case-insensitive Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
true,
"Windows paths with different casing should match (case-insensitive)",
);
console.log("✓ Case-insensitive Windows descendant match\n");
}
// Test 16: Case-insensitive Windows exact match
{
console.log("Test 16: Case-insensitive Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
true,
"Windows paths with different casing should match exactly (case-insensitive)",
);
console.log("✓ Case-insensitive Windows exact match\n");
}
// Test 17: Parent should not match
{
console.log("Test 15: Parent should not match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
false,
"parent directory should not match (only same-or-descendant)",
);
console.log("✓ Parent should not match\n");
}
console.log("=== All CWD filter path tests passed ===");
+3 -2
View File
@@ -9,5 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
exit /b 1 exit /b 1
) )
set "PASEO_DESKTOP_CLI=1" set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" %* "%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel%
@@ -6,5 +6,7 @@
<true/> <true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key> <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict> </dict>
</plist> </plist>
@@ -6,5 +6,7 @@
<true/> <true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key> <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict> </dict>
</plist> </plist>
+5
View File
@@ -14,6 +14,7 @@ publish:
owner: getpaseo owner: getpaseo
repo: paseo repo: paseo
mac: mac:
artifactName: "Paseo-${version}-${arch}.${ext}"
category: public.app-category.developer-tools category: public.app-category.developer-tools
icon: assets/icon.icns icon: assets/icon.icns
hardenedRuntime: true hardenedRuntime: true
@@ -30,11 +31,15 @@ linux:
category: Development category: Development
icon: assets icon: assets
artifactName: "Paseo-${version}-${arch}.${ext}" artifactName: "Paseo-${version}-${arch}.${ext}"
maintainer: "Mohamed Boudra <hello@moboudra.com>"
vendor: "Paseo"
extraResources: extraResources:
- from: bin/paseo - from: bin/paseo
to: bin/paseo to: bin/paseo
target: target:
- AppImage - AppImage
- deb
- rpm
- tar.gz - tar.gz
win: win:
icon: assets/icon.ico icon: assets/icon.ico
+15 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.32", "version": "0.1.37",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -12,8 +12,9 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.32", "@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.32", "@getpaseo/server": "0.1.37",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
@@ -25,5 +26,15 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"unzip-crx-3": "^0.2.0", "unzip-crx-3": "^0.2.0",
"wait-on": "8.0.5" "wait-on": "8.0.5"
} },
"homepage": "https://paseo.sh",
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"license": "AGPL-3.0-or-later"
} }
+118 -1
View File
@@ -7,8 +7,125 @@ const WRAPPER_SCRIPT = `#!/bin/bash
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@" exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
`; `;
// electron-builder arch enum → Node.js arch string
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
const RIPGREP_PLATFORM_DIR = {
darwin: { arm64: "arm64-darwin", x64: "x64-darwin" },
linux: { arm64: "arm64-linux", x64: "x64-linux" },
win32: { arm64: "arm64-win32", x64: "x64-win32" },
};
function rmSafe(target) {
fs.rmSync(target, { recursive: true, force: true });
}
function pruneChildrenExcept(parent, keep) {
if (!fs.existsSync(parent)) return;
for (const entry of fs.readdirSync(parent)) {
if (!keep.has(entry)) {
rmSafe(path.join(parent, entry));
}
}
}
function pruneOnnxRuntime(nodeModules, platform, arch) {
const onnxBin = path.join(nodeModules, "onnxruntime-node", "bin", "napi-v6");
if (!fs.existsSync(onnxBin)) return;
const otherPlatforms = ["darwin", "linux", "win32"].filter((p) => p !== platform);
for (const p of otherPlatforms) {
rmSafe(path.join(onnxBin, p));
}
pruneChildrenExcept(path.join(onnxBin, platform), new Set([arch]));
if (platform === "linux") {
const archDir = path.join(onnxBin, "linux", arch);
if (fs.existsSync(archDir)) {
for (const name of fs.readdirSync(archDir)) {
if (name.includes("cuda") || name.includes("tensorrt")) {
fs.rmSync(path.join(archDir, name), { force: true });
}
}
}
}
}
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (!keepName) return;
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
function pruneNodePty(nodeModules, platform, arch) {
const prebuilds = path.join(nodeModules, "node-pty", "prebuilds");
pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`]));
if (platform !== "win32") {
rmSafe(path.join(nodeModules, "node-pty", "third_party"));
}
}
function pruneSharpLibvips(nodeModules, platform, arch) {
const prefix = `sharp-libvips-${platform}-${arch}`;
const imgDir = path.join(nodeModules, "@img");
if (!fs.existsSync(imgDir)) return;
for (const entry of fs.readdirSync(imgDir)) {
if (entry.startsWith("sharp-") && entry !== prefix && !entry.startsWith(`sharp-${platform}-${arch}`)) {
rmSafe(path.join(imgDir, entry));
}
}
}
function pruneNativeModules(appOutDir, platform, arch) {
const resourcesDir =
platform === "darwin"
? path.join(appOutDir, `${EXECUTABLE_NAME}.app`, "Contents", "Resources")
: path.join(appOutDir, "resources");
const nodeModules = path.join(resourcesDir, "app.asar.unpacked", "node_modules");
if (!fs.existsSync(nodeModules)) return;
const before = dirSizeSync(nodeModules);
pruneOnnxRuntime(nodeModules, platform, arch);
pruneClaudeAgentSdk(nodeModules, platform, arch);
pruneNodePty(nodeModules, platform, arch);
pruneSharpLibvips(nodeModules, platform, arch);
const after = dirSizeSync(nodeModules);
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
console.log(`Pruned native modules: ${savedMB} MB removed (${fmtMB(before)} → ${fmtMB(after)})`);
}
function dirSizeSync(dir) {
let total = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
if (entry.isFile()) {
try {
total += fs.statSync(path.join(entry.parentPath || entry.path, entry.name)).size;
} catch {}
}
}
return total;
}
function fmtMB(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
exports.default = async function afterPack(context) { exports.default = async function afterPack(context) {
if (context.electronPlatformName !== "linux") return; const platform = context.electronPlatformName;
const arch = ARCH_MAP[context.arch] || process.arch;
pruneNativeModules(context.appOutDir, platform, arch);
if (platform !== "linux") return;
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox"); const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
if (fs.existsSync(chromeSandbox)) { if (fs.existsSync(chromeSandbox)) {
@@ -148,8 +148,6 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
); );
origins.add("paseo://app");
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL; const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
try { try {
const parsed = new URL(devServerUrl); const parsed = new URL(devServerUrl);
@@ -189,7 +187,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") || normalized.startsWith("/") ||
normalized.startsWith("unix://") || normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") || normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) { ) {
return null; return null;
} }
@@ -544,12 +543,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
return downloadAndInstallUpdate(currentVersion); return downloadAndInstallUpdate(currentVersion);
}, },
get_local_daemon_version: () => getLocalDaemonVersion(), get_local_daemon_version: () => getLocalDaemonVersion(),
webview_log: (args) => {
const level = typeof args?.level === "number" ? args.level : 1;
const message = typeof args?.message === "string" ? args.message : "";
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
console[method]("[webview]", message);
},
}; };
} }
+25 -1
View File
@@ -168,6 +168,30 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
}; };
} }
function resolveNodeExecPath(): string {
if (app.isPackaged && process.platform === "darwin") {
const marker = ".app/Contents/MacOS/";
const markerIndex = process.execPath.indexOf(marker);
if (markerIndex !== -1) {
const bundleRoot = process.execPath.substring(0, markerIndex + ".app".length);
const name = path.basename(process.execPath);
const helperPath = path.join(
bundleRoot,
"Contents",
"Frameworks",
`${name} Helper.app`,
"Contents",
"MacOS",
`${name} Helper`,
);
if (existsSync(helperPath)) {
return helperPath;
}
}
}
return process.execPath;
}
export function createNodeEntrypointInvocation(input: { export function createNodeEntrypointInvocation(input: {
entrypoint: NodeEntrypointSpec; entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode; argvMode: NodeEntrypointArgvMode;
@@ -175,7 +199,7 @@ export function createNodeEntrypointInvocation(input: {
baseEnv: NodeJS.ProcessEnv; baseEnv: NodeJS.ProcessEnv;
}): NodeEntrypointInvocation { }): NodeEntrypointInvocation {
return createSharedNodeEntrypointInvocation({ return createSharedNodeEntrypointInvocation({
execPath: process.execPath, execPath: resolveNodeExecPath(),
isPackaged: app.isPackaged, isPackaged: app.isPackaged,
packagedRunnerPath: app.isPackaged packagedRunnerPath: app.isPackaged
? assertPathExists({ ? assertPathExists({
+14 -2
View File
@@ -1,3 +1,6 @@
import log from "electron-log/main";
log.initialize({ spyRendererConsole: true });
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
@@ -89,8 +92,17 @@ async function createMainWindow(): Promise<void> {
height: 800, height: 800,
show: false, show: false,
...(iconPath ? { icon: iconPath } : {}), ...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: isMac ? "hidden" : "default", titleBarStyle: "hidden",
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined, ...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
},
autoHideMenuBar: true,
}),
webPreferences: { webPreferences: {
preload: getPreloadPath(), preload: getPreloadPath(),
contextIsolation: true, contextIsolation: true,
+1
View File
@@ -1,3 +1,4 @@
import "electron-log/preload";
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.32", "version": "0.1.37",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+8 -3
View File
@@ -1,10 +1,15 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.32", "version": "0.1.37",
"private": true,
"type": "module", "type": "module",
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build", "prepack": "npm run build",

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