Compare commits

..

37 Commits

Author SHA1 Message Date
Mohamed Boudra
05734e8b1b chore(release): cut 0.1.52 2026-04-10 17:33:21 +07:00
Mohamed Boudra
c2f3bb73a6 docs(changelog): add 0.1.52 release notes 2026-04-10 17:32:32 +07:00
Mohamed Boudra
1ea5e5a769 fix: remove dead backpressure code left over from terminal stream fix 2026-04-10 17:19:42 +07:00
Mohamed Boudra
49e67363b2 feat(app): add themeable diff stat colors and soften tooltip border
Add diffAddition/diffDeletion semantic tokens with separate light
(muted green-700/red-700) and dark (bright green-400/red-500) sets,
spread into themes so individual variants can override. Update all
diff stat consumers to use the new tokens.

Soften tooltip border to match dropdown menu styling (thinner border,
accent color, medium shadow).
2026-04-10 17:06:41 +07:00
Mohamed Boudra
b9a8ba054d fix(app): keep diff rows aligned for blank lines 2026-04-10 16:55:04 +07:00
Mohamed Boudra
2f77674c55 feat: add theme selector with multiple dark themes
Add theme dropdown in settings with Light, Dark, System plus
Zinc, Midnight, Claude, and Ghostty dark variants. Each theme
has its own accent color and surface tints. Desaturate terminal
ANSI colors across all dark themes. Add surfaceSidebarHover
semantic token so hover states go the right direction in both
light and dark modes. Self-heal if a stored theme no longer exists.
2026-04-10 16:21:05 +07:00
Mohamed Boudra
120c1b46a4 fix: remove terminal stream backpressure that caused 14MB snapshot thrashing
The high water mark (256KB) was triggering snapshot mode during normal
output bursts, replacing incremental output with full terminal state
snapshots (~14MB). This made the terminal feel laggy even on localhost.

Snapshots still fire on initial attach and tab switch where they're
semantically needed.
2026-04-10 15:12:32 +07:00
Mohamed Boudra
033c4bcbf2 fix: let xterm.js handle keyboard input natively for scroll-on-input
Only intercept keys when mobile virtual modifier buttons are active.
Previously all special keys (Enter, Ctrl+C, arrows) were intercepted
and manually re-encoded, bypassing xterm's built-in scrollOnUserInput.
2026-04-10 15:11:14 +07:00
Mohamed Boudra
0f005172ce chore: use build:daemon in worktree setup 2026-04-10 14:42:39 +07:00
Mohamed Boudra
0e7dfcaf2a docs: clean up CONTRIBUTING.md wording 2026-04-10 13:59:54 +07:00
Mohamed Boudra
ebfad945e5 docs: add CONTRIBUTING.md with BDFL expectations and PR requirements 2026-04-10 13:47:36 +07:00
Mohamed Boudra
b5a0ee9954 feat: branch switching with stash-and-switch (#231)
* feat: add branch switching and stash management to workspace header

Adds the ability to switch git branches from the workspace header by
clicking the branch name. Includes full stash support: when switching
away from a dirty branch, users are prompted to stash changes; when
switching to a branch with a Paseo stash, they're prompted to restore.
An amber stash indicator in the header provides anytime restore/discard.

New WebSocket messages: checkout_switch_branch, stash_save, stash_pop,
stash_drop, stash_list. Stashes are tracked via git stash messages
with a "paseo-auto-stash:" prefix convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: await query invalidation after branch switch and stash

The branch switcher became non-clickable after switching because
checkout query invalidation was fire-and-forget. Now we await
invalidation so the UI has fresh checkout status (including the new
branch name) before rendering. Also invalidate after stash-save
so the checkout query sees clean state before the switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: emit workspace update immediately after branch switch

The sidebar and header were slow to reflect the new branch name because
they relied on the daemon's background git watcher to detect changes.
Now the switch handler calls emitWorkspaceUpdateForCwd immediately after
checkout, pushing the updated workspace descriptor to connected clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: stash detection and dirty-tree branch switch handling

- Fix stash list parser: git prepends "On <branch>: " to stash
  messages, so use indexOf instead of startsWith to find the
  paseo-auto-stash prefix.
- Remove switchBranchMutation in favor of inline async flow in
  handleBranchSelect. Now tries the switch first; if the server
  returns an uncommitted-changes error, offers the stash dialog
  automatically — no more red error toast on first attempt.
- Move post-switch stash restore prompt into handleBranchSelect
  so the full flow (switch → check stashes → prompt) is sequential.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: toast.success → toast.show and await stash invalidation

- Replace toast.success() with toast.show() — the ToastApi only
  exposes show/copied/error, not success.
- Await invalidateStashAndCheckout in stashPop and stashDrop mutation
  onSuccess handlers so the stash indicator disappears immediately
  after restore/discard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add stash diff preview and multi-stash management

- Add stash_show message pair: server runs `git stash show -p` and
  returns ParsedDiffFile[] through the existing diff parser.
- Stash indicator dropdown now lists ALL Paseo stashes (not just
  current branch) with per-stash Preview, Restore, and Discard actions.
- Preview opens a full diff modal showing file-by-file changes with
  syntax-colored add/remove lines, file stats, and Restore/Discard
  action buttons.
- Clean up: remove unused handleRestoreStash/handleDropStash callbacks,
  add Eye/Trash2 icons, import Modal/ScrollView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use Fonts.mono instead of theme.fontFamily.mono

The theme object doesn't have a fontFamily property. The codebase uses
the Fonts constant from @/constants/theme for font family values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: filter stashes to current branch and handle restore conflicts

- Stash indicator only shows when the current branch has stashes,
  not all branches. Switching branches hides/shows it correctly.
- When restoring a stash conflicts with uncommitted changes, offers
  to stash current changes first then restore the target stash
  (auto-adjusts stash index after the new save).
- Multiple stashes on the same branch show as "Stash 1", "Stash 2"
  instead of repeating the branch name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve branch switcher with recency sort, sticky search, and branch icons

- Sort branches by committer date (most recent first) instead of alphabetical
- Use full refname to fix normalization of branches with slashes
- Filter out bare "origin" symref leaking as a branch name
- Remove server-side search query; fetch all branches once and filter client-side
- Port combobox sticky search design from dev (borderless bar above scroll area)
- Add GitBranch icons to branch switcher items
- Prioritize prefix matches in combobox search results

* fix: move useIsCompactFormFactor hook above early return in ToastViewport

The hook was called after `if (!toast) return null`, violating React's
rules of hooks and causing "Rendered more hooks than during the previous
render" crash.

* fix: include untracked files in stash-and-switch flow

git stash push without --include-untracked left untracked files behind,
causing the clean-tree check to fail on the subsequent checkout.

* refactor: strip stash management UI, keep stash-and-switch flow

Remove the stash indicator icon, dropdown menu, preview modal,
and stash_drop/stash_show RPCs. Keep the auto-stash on branch switch
and the restore prompt on arrival — that's the useful surface.

* refactor: extract filterAndRankComboboxOptions with tests

* refactor: extract BranchSwitcher component and useBranchSwitcher hook

---------

Co-authored-by: heyimsteve <8645831+heyimsteve@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:26:04 +08:00
Mohamed Boudra
8a9d738438 Fix sync loader spinner showing for initializing (non-running) agents
Initializing agents were mapped to the "running" bucket in both server
and client, causing the SyncedLoader spinner to appear in tabs and
sidebar workspace rows when opening an agent for the first time.
2026-04-10 11:13:14 +07:00
Mohamed Boudra
1d41a50e1f Background revalidate provider models when model selector opens
On fresh startup the initial provider snapshot request can return
before providers finish loading, leaving the model picker empty.
The server broadcasts updates once providers are ready, but if the
user opens the picker in the interim they see "No models match".

Invalidate the React Query cache (background refetch, no loading
flash) every time the model selector popup opens so stale data is
silently refreshed.
2026-04-10 11:01:01 +07:00
Mohamed Boudra
32fe4b3beb Support wildcard CORS in WebSocket verifyClient and dev.sh 2026-04-10 10:45:36 +07:00
Mohamed Boudra
a692c616cb Fix error screen not scrollable on Electron 2026-04-10 10:45:10 +07:00
Mohamed Boudra
edd5503fe8 Support wildcard CORS origin via PASEO_CORS_ORIGINS=* 2026-04-10 10:35:48 +07:00
Mohamed Boudra
34bd8dfd1b Stable PASEO_HOME for worktrees and shared speech models in dev.sh
Worktrees get a persistent ~/.paseo-<name> home instead of a temp dir.
Speech models point at ~/.paseo/models/local-speech to avoid re-downloads.
2026-04-10 10:29:34 +07:00
Mohamed Boudra
201eb6a671 Self-contained desktop dev script with worktree isolation
dev:desktop now launches its own Metro on a random port + Electron,
no dependency on a shared :8081. In worktrees, Electron gets a unique
userData path and dock badge so multiple instances run side-by-side.
2026-04-10 10:11:55 +07:00
Mohamed Boudra
9f09a19a09 Gate Claude SDK stream trace logs behind PASEO_CLAUDE_DEBUG
Per-token trace logs from the query pump fire twice per streaming event
and contribute to memory growth under heavy use. Gate them behind an
opt-in env var so they're available for debugging but silent by default.
2026-04-10 10:05:13 +07:00
Mohamed Boudra
edfd2564a3 Change default file log level from trace to debug
Trace-level logging emits two entries per streaming token per session,
causing significant memory growth and GC pressure under multi-workspace
use. Fixes #227.
2026-04-10 10:01:39 +07:00
Mohamed Boudra
c1ebb8c915 Refactor form factor check to reactive useIsCompactFormFactor 2026-04-10 09:53:38 +07:00
Mohamed Boudra
29af07e383 Fix live agent refresh without persistence 2026-04-10 09:53:38 +07:00
Mohamed Boudra
17bd036359 Persist draft agent feature preferences 2026-04-10 09:53:38 +07:00
Mohamed Boudra
2683dae5f9 Auto-download desktop updates before prompting 2026-04-10 09:53:38 +07:00
Mohamed Boudra
914a5dc6ae ci: add comprehensive CI workflow 2026-04-10 00:20:42 +07:00
github-actions[bot]
30842398e7 fix: update lockfile signatures and Nix hash 2026-04-09 07:00:28 +00:00
Mohamed Boudra
b27c1b0729 chore(release): cut 0.1.51 2026-04-09 13:58:59 +07:00
Mohamed Boudra
fcf2c14485 docs(changelog): add 0.1.51 release notes 2026-04-09 13:57:44 +07:00
Mohamed Boudra
57eafda6ef fix(app): fix BottomSheetTextInput crash on iPad model selector
On iPad, Combobox uses the desktop Modal path (no BottomSheet context)
since the breakpoint is "md"+, but ProviderSearchInput was checking
Platform.OS === "web" to choose the input component. This caused
BottomSheetTextInput to render outside a BottomSheet on iPad.

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

* Fix rebase type errors and add TODO date tag

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

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-09 10:26:53 +08:00
Benjamin Kitt
d98d5457ed Fix commands not loading for Pi agent (#225) 2026-04-09 10:10:11 +08:00
103 changed files with 3698 additions and 1157 deletions

171
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,171 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck all packages
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
app-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
relay-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
PASEO_DICTATION_ENABLED: '0'
PASEO_VOICE_MODE_ENABLED: '0'

View File

@@ -1,5 +1,38 @@
# Changelog
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
- Initializing agents no longer show a loading spinner as if they're running.
- Reconnecting to a running agent now works even when session persistence is unavailable.
- Error screens on desktop are now scrollable.
- Model list refreshes in the background when you open the model selector.
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added

168
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,168 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
This means:
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.

428
docs/DATA_MODEL.md Normal file
View File

@@ -0,0 +1,428 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### Nested: ScheduleTarget (discriminated union on `type`)
- `{ type: "agent", agentId: string }` — send to existing agent
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 4. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
Single file containing all rooms and messages.
```json
{
"rooms": [ ... ],
"messages": [ ... ]
}
```
### ChatRoom
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
## 5. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
|---|---|---|
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
|---|---|
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
|---|---|
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
|---|---|
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
## 6. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
|---|---|---|
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
npmDepsHash = "sha256-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M=";
# 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).

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@getpaseo/expo-two-way-audio": "0.1.52",
"@getpaseo/highlight": "0.1.52",
"@getpaseo/server": "0.1.52",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35032,11 +35032,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.52",
"@getpaseo/server": "0.1.52",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35077,11 +35077,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@getpaseo/cli": "0.1.52",
"@getpaseo/server": "0.1.52",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35115,7 +35115,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35316,7 +35316,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35342,7 +35342,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35358,14 +35358,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.52",
"@getpaseo/relay": "0.1.52",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35764,7 +35764,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -57,7 +58,7 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
@@ -372,7 +373,7 @@ function AppContainer({
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => {
@@ -561,7 +562,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
}
}, [settingsLoading, settings.theme]);

View File

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

View File

@@ -3,7 +3,8 @@ import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
@@ -119,7 +120,7 @@ export function AdaptiveModalSheet({
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -239,7 +240,7 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -151,7 +152,7 @@ export function AddHostModal({
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
@@ -218,6 +219,7 @@ export function AddHostModal({
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });

View File

@@ -1,6 +1,7 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated";
@@ -48,6 +49,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = {
id: string;
@@ -131,6 +133,8 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -151,10 +155,8 @@ export function AgentInputArea({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isDesktopWebBreakpoint =
Platform.OS === "web" &&
UnistylesRuntime.breakpoint !== "xs" &&
UnistylesRuntime.breakpoint !== "sm";
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER;
@@ -742,6 +744,7 @@ export function AgentInputArea({
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}

View File

@@ -10,7 +10,8 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -214,7 +215,7 @@ export function AgentList({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,

View File

@@ -88,6 +88,7 @@ type ControlledAgentStatusBarProps = {
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
};
export interface DraftAgentStatusBarProps {
@@ -110,6 +111,7 @@ export interface DraftAgentStatusBarProps {
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
disabled?: boolean;
}
@@ -217,6 +219,7 @@ function ControlledStatusBar({
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -411,6 +414,7 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
/>
</View>
@@ -662,6 +666,7 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
<View
@@ -875,6 +880,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId);
const snapshotModels = useMemo(() => {
@@ -962,13 +968,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
(current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
@@ -978,7 +985,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
@@ -991,16 +998,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
(current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
},
}),
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
@@ -1014,11 +1022,26 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
if (!client) {
return;
}
void updatePreferences(
(current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist feature preference failed", error);
});
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={snapshotIsLoading || snapshotIsFetching}
onModelSelectorOpen={invalidateSnapshot}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
@@ -1045,6 +1068,7 @@ export function DraftAgentStatusBar({
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
@@ -1083,12 +1107,13 @@ export function DraftAgentStatusBar({
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
isLoading={isAllModelsLoading}
disabled={disabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
/>
<ControlledStatusBar
@@ -1129,7 +1154,7 @@ export function DraftAgentStatusBar({
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
@@ -1138,6 +1163,7 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onModelSelectorOpen={onModelSelectorOpen}
disabled={disabled}
/>
</>

View File

@@ -9,7 +9,8 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import Animated, {
@@ -109,7 +110,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
resolveStreamRenderStrategy({
@@ -707,7 +708,7 @@ function PermissionRequestCard({
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const { request } = permission;
const isPlanRequest = request.kind === "plan";

View File

@@ -0,0 +1,125 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
interface BranchSwitcherProps {
currentBranchName: string | null;
title: string;
branchOptions: ComboboxOption[];
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onBranchSelect: (branchId: string) => void;
}
export function BranchSwitcher({
currentBranchName,
title,
branchOptions,
isOpen,
onOpenChange,
onBranchSelect,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
if (!currentBranchName) {
return (
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{title}
</Text>
);
}
return (
<View ref={anchorRef} collapsable={false}>
<Pressable
testID="workspace-header-branch-switcher"
onPress={() => onOpenChange(true)}
style={({ hovered, pressed }) => [
styles.branchSwitcherTrigger,
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
]}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch
size={14}
color={theme.colors.foregroundMuted}
/>
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{title}
</Text>
<ChevronDown
size={12}
color={theme.colors.foregroundMuted}
/>
</Pressable>
<Combobox
options={branchOptions}
value={currentBranchName}
onSelect={onBranchSelect}
searchable
placeholder="Switch branch..."
searchPlaceholder="Filter branches..."
emptyText="No branches found."
title="Switch branch"
open={isOpen}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={280}
renderOption={({ option, selected, active, onPress }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<GitBranch
size={14}
color={theme.colors.foregroundMuted}
/>
}
/>
)}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
branchSwitcherTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
minWidth: 0,
},
branchSwitcherTriggerHovered: {
backgroundColor: theme.colors.surface1,
},
}));

View File

@@ -10,6 +10,7 @@ import {
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
ArrowLeft,
ChevronDown,
@@ -58,6 +59,7 @@ interface CombinedModelSelectorProps {
disabled: boolean;
isOpen: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
disabled?: boolean;
}
@@ -348,7 +350,8 @@ function ProviderSearchInput({
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {
@@ -515,6 +518,7 @@ export function CombinedModelSelector({
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
onOpen,
onClose,
disabled = false,
}: CombinedModelSelectorProps) {
@@ -539,12 +543,14 @@ export function CombinedModelSelector({
(open: boolean) => {
setIsOpen(open);
setView(singleProviderView ?? { kind: "all" });
if (!open) {
if (open) {
onOpen?.();
} else {
setSearchQuery("");
onClose?.();
}
},
[onClose, singleProviderView],
[onOpen, onClose, singleProviderView],
);
const handleSelect = useCallback(

View File

@@ -20,7 +20,7 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -48,7 +48,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
tabActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
tabText: {
fontSize: theme.fontSize.sm,

View File

@@ -10,7 +10,8 @@ import {
Platform,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
cancelAnimation,
Easing,
@@ -89,7 +90,7 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
@@ -848,7 +849,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
indentGuide: {
position: "absolute",

View File

@@ -8,7 +8,8 @@ import {
View,
Platform,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
@@ -241,7 +242,7 @@ export function FilePane({
workspaceRoot: string;
filePath: string;
}) {
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);

View File

@@ -21,7 +21,8 @@ import {
TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
Archive,
@@ -56,7 +57,12 @@ import {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type SplitDiffDisplayLine,
type SplitDiffRow,
} from "@/utils/diff-layout";
import {
DropdownMenu,
DropdownMenuContent,
@@ -74,6 +80,11 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -164,7 +175,7 @@ function DiffGutterCell({
type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
{formatDiffGutterText(lineNumber)}
</Text>
</View>
);
@@ -177,10 +188,12 @@ function DiffTextLine({
line: DiffLine;
wrapLines: boolean;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
{line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text
style={[
@@ -192,7 +205,7 @@ function DiffTextLine({
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
{formatDiffContentText(line.content)}
</Text>
)}
</View>
@@ -206,10 +219,12 @@ function SplitTextLine({
line: SplitDiffDisplayLine | null;
wrapLines: boolean;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text
style={[
@@ -221,7 +236,7 @@ function SplitTextLine({
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
{formatDiffContentText(line?.content)}
</Text>
)}
</View>
@@ -239,6 +254,8 @@ function DiffLineView({
gutterWidth: number;
wrapLines: boolean;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
<View
style={[
@@ -254,11 +271,11 @@ function DiffLineView({
line.type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
{formatDiffGutterText(lineNumber)}
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text
style={[
@@ -270,7 +287,7 @@ function DiffLineView({
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
{formatDiffContentText(line.content)}
</Text>
)}
</View>
@@ -286,6 +303,8 @@ function SplitDiffLine({
gutterWidth: number;
wrapLines: boolean;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
<View
style={[
@@ -301,11 +320,11 @@ function SplitDiffLine({
line?.type === "remove" && styles.removeLineNumberText,
]}
>
{line?.lineNumber != null ? String(line.lineNumber) : ""}
{formatDiffGutterText(line?.lineNumber ?? null)}
</Text>
</View>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text
style={[
@@ -317,7 +336,7 @@ function SplitDiffLine({
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
{formatDiffContentText(line?.content)}
</Text>
)}
</View>
@@ -534,26 +553,7 @@ function DiffFileBody({
);
}
const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = [];
for (const [hunkIndex, hunk] of file.hunks.entries()) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
for (const [lineIndex, line] of hunk.lines.entries()) {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` });
}
}
const computedLines = buildUnifiedDiffLines(file);
if (wrapLines) {
return (
@@ -607,7 +607,7 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter();
@@ -1724,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({
newBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
color: theme.colors.diffAddition,
},
deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)",
@@ -1736,17 +1736,17 @@ const styles = StyleSheet.create((theme) => ({
deletedBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
color: theme.colors.diffDeletion,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
color: theme.colors.diffAddition,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
color: theme.colors.diffDeletion,
},
diffContent: {
borderTopWidth: theme.borderWidth[1],
@@ -1824,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.palette.green[400],
color: theme.colors.diffAddition,
},
removeLineNumberText: {
color: theme.colors.palette.red[500],
color: theme.colors.diffDeletion,
},
diffLineText: {
flex: 1,

View File

@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { isCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
@@ -44,7 +44,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);

View File

@@ -6,7 +6,7 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
useIsCompactFormFactor,
} from "@/constants/layout";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -26,7 +26,7 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const padding = useWindowControlsPadding("header");
// 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;

View File

@@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
isCompactFormFactor,
useIsCompactFormFactor,
} from "@/constants/layout";
import {
buildHostSessionsRoute,
@@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostTrigger: {
flexDirection: "row",
@@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
},
hostTriggerHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostStatusDot: {
width: 8,

View File

@@ -87,6 +87,9 @@ export interface MessageInputProps {
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
/** Controls what the default send action (Enter, send button, dictation) does
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
defaultSendBehavior?: "interrupt" | "queue";
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -208,6 +211,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
voiceServerId,
voiceAgentId,
isAgentRunning = false,
defaultSendBehavior = "interrupt",
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
@@ -352,11 +356,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
// Respect send behavior setting: when "queue", dictation queues too.
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
onQueue({ text: nextValue, images: imageAttachments });
onChangeText("");
} else {
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
}
} else {
onChangeText(nextValue);
}
@@ -367,7 +377,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
});
}
},
[onChangeText, onSubmit, images, isAgentRunning],
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -578,6 +588,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]);
// Default send action: respects the sendBehavior setting.
// When "interrupt" (default), primary action sends immediately (interrupts).
// When "queue", primary action queues when agent is running.
const handleDefaultSendAction = useCallback(() => {
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
handleQueueMessage();
} else {
handleSendMessage();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
// Alternate send action: always the opposite of the default.
const handleAlternateSendAction = useCallback(() => {
if (defaultSendBehavior === "queue") {
handleSendMessage(); // interrupt
} else if (onQueue) {
handleQueueMessage(); // queue
}
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
@@ -872,18 +902,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return;
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleQueueMessage();
handleAlternateSendAction();
return;
}
// Enter: send (interrupts agent if running)
// Enter: default send action (interrupt or queue, based on setting)
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleSendMessage();
handleDefaultSendAction();
}
const hasImages = images.length > 0;
@@ -892,11 +922,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: isAgentRunning
? "Send and interrupt"
: "Send message";
: defaultActionQueues
? "Queue message"
: isAgentRunning
? "Send and interrupt"
: "Send message";
const handleInputChange = useCallback(
(nextValue: string) => {
@@ -1071,10 +1104,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && (
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
onPress={handleAlternateSendAction}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
@@ -1099,7 +1132,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
@@ -1113,7 +1146,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>

View File

@@ -41,7 +41,8 @@ import {
Scissors,
MicVocal,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
cancelAnimation,
@@ -1822,7 +1823,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {

View File

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

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -66,7 +67,7 @@ export function PairLinkModal({
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -146,7 +147,7 @@ export function PairLinkModal({
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -63,7 +64,7 @@ const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});

View File

@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -713,7 +713,7 @@ function ProjectHeaderRow({
}: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = isCompactFormFactor();
const isMobileBreakpoint = useIsCompactFormFactor();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
@@ -1606,7 +1606,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
@@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
projectRowHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectRowPressed: {
backgroundColor: theme.colors.surface2,
@@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
projectActionButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectActionButtonText: {
color: theme.colors.foregroundMuted,
@@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
projectIconActionButtonHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectIconActionButtonHidden: {
opacity: 0,
@@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
workspaceRowHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
@@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowContainer: {
position: "relative",
@@ -2241,12 +2241,12 @@ const styles = StyleSheet.create((theme) => ({
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
color: theme.colors.diffAddition,
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
color: theme.colors.diffDeletion,
},
kebabButton: {
padding: 2,

View File

@@ -21,7 +21,7 @@ import {
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { isCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
interface TerminalPaneProps {
serverId: string;
@@ -92,7 +92,7 @@ export function TerminalPane({
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { createPortal } from "react-dom";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -108,6 +109,7 @@ export function ToastViewport({
}) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -181,7 +183,6 @@ export function ToastViewport({
return null;
}
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildVisibleComboboxOptions,
filterAndRankComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
} from "./combobox-options";
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
});
});
describe("filterAndRankComboboxOptions", () => {
const options = [
{ id: "feat/login", label: "feat/login" },
{ id: "main", label: "main" },
{ id: "feat/main-nav", label: "feat/main-nav" },
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
];
it("returns all options when search is empty", () => {
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
});
it("filters by label substring", () => {
const result = filterAndRankComboboxOptions(options, "login");
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
});
it("filters by id substring", () => {
const result = filterAndRankComboboxOptions(options, "fix/");
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
});
it("filters by description substring", () => {
const result = filterAndRankComboboxOptions(options, "logout bug");
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
});
it("ranks prefix matches above substring matches", () => {
const result = filterAndRankComboboxOptions(options, "main");
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
});
it("is case-insensitive", () => {
const items = [{ id: "Alpha", label: "Alpha" }];
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
});
it("returns empty when nothing matches", () => {
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
});
});
describe("combobox above-search ordering", () => {
const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },

View File

@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
);
}
export function filterAndRankComboboxOptions(
options: ComboboxOptionModel[],
search: string,
): ComboboxOptionModel[] {
if (!search) return options;
return options
.filter(
(opt) =>
opt.label.toLowerCase().includes(search) ||
opt.id.toLowerCase().includes(search) ||
opt.description?.toLowerCase().includes(search),
)
.sort((a, b) => {
const aPrefix =
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
const bPrefix =
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
return 0;
});
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput,
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch),
)
: input.options;
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({

View File

@@ -11,7 +11,8 @@ import {
StatusBar,
useWindowDimensions,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetModal,
BottomSheetScrollView,
@@ -261,7 +262,7 @@ export function Combobox({
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
@@ -659,13 +660,7 @@ export function Combobox({
</>
);
const defaultContent = (
<>
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
);
const defaultContent = optionsList;
const content = children ?? defaultContent;
@@ -690,6 +685,7 @@ export function Combobox({
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
{stickyHeader}
{!children && searchable ? searchInput : null}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
@@ -745,6 +741,7 @@ export function Combobox({
</>
) : (
<>
{searchable ? searchInput : null}
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
@@ -759,9 +756,7 @@ export function Combobox({
>
{optionsList}
</ScrollView>
) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? (
) : (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -770,7 +765,7 @@ export function Combobox({
>
{optionsList}
</ScrollView>
) : null}
)}
</>
)}
</Animated.View>
@@ -783,15 +778,12 @@ const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
marginTop: theme.spacing[1],
gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
searchInput: {
flex: 1,

View File

@@ -27,7 +27,8 @@ import {
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -347,7 +348,7 @@ export function ContextMenuContent({
testID?: string;
}>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent");
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);

View File

@@ -551,9 +551,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
...theme.shadow.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
...theme.shadow.md,
zIndex: 1000,
},
}));

View File

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

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { UnistylesRuntime } from "react-native-unistyles";
import { useUnistyles } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -62,16 +62,13 @@ export function getIsElectronRuntime(): boolean {
return result;
}
export function isCompactFormFactor(): boolean {
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
}
export function isDesktopFormFactor(): boolean {
return !isCompactFormFactor();
}
export function isTouchDesktopFormFactor(): boolean {
return Platform.OS !== "web" && isDesktopFormFactor();
/**
* Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
*/
export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles();
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.

View File

@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);

View File

@@ -10,7 +10,7 @@ import {
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
@@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import {
@@ -21,15 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
export interface LocalDaemonSectionProps {
appVersion: string | null;
@@ -40,44 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void loadDaemonData();
return undefined;
}, [loadDaemonData, showSection]),
);
const daemonStatus = data?.status ?? null;
const daemonLogs = data?.logs ?? null;
const daemonVersion = daemonStatus?.version ?? null;
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
@@ -116,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action()
.then((status) => {
setDaemonStatus(status);
.then((newStatus) => {
setStatus(newStatus);
setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
);
return loadDaemonData();
refetch();
})
.catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", error);
@@ -136,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -182,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadDaemonData())
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped.");
})
.catch((error) => {
@@ -202,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadDaemonData,
refetch,
setStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
@@ -277,126 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings
</Button>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
{isLoading ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
{showLifecycleControls ? (
<>
) : (
<>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
{daemonLogs?.logPath ?? "Log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
) : null}
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
) : null}
</>
)}
<AdaptiveModalSheet
visible={isLogsModalOpen}
@@ -447,6 +435,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
justifyContent: "flex-end",
},
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,

View File

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

View File

@@ -4,6 +4,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string | null;
latestVersion: string | null;
body: string | null;
@@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
return {
hasUpdate: result.hasUpdate === true,
readyToInstall: result.readyToInstall === true,
currentVersion: toStringOrNull(result.currentVersion),
latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body),

View File

@@ -55,7 +55,7 @@ export function UpdateBanner() {
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isInstalling) return "Installing and restarting...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
checkDesktopAppUpdate,
formatVersionWithPrefix,
@@ -11,12 +11,15 @@ import {
export type DesktopAppUpdateStatus =
| "idle"
| "checking"
| "pending"
| "up-to-date"
| "available"
| "installing"
| "installed"
| "error";
const PENDING_RECHECK_MS = 10_000;
export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean;
status: DesktopAppUpdateStatus;
@@ -56,11 +59,15 @@ function formatStatusText(input: {
return "App is up to date.";
}
if (status === "pending") {
return "We'll let you know when the update is ready.";
}
if (status === "available") {
if (availableUpdate?.latestVersion) {
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
}
return "An app update is available.";
return "An app update is ready to install.";
}
if (status === "installed") {
@@ -106,9 +113,12 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setInstallMessage(null);
setLastCheckedAt(Date.now());
if (result.hasUpdate) {
if (result.readyToInstall) {
setAvailableUpdate(result);
setStatus("available");
} else if (result.hasUpdate) {
setAvailableUpdate(null);
setStatus("pending");
} else {
setAvailableUpdate(null);
setStatus("up-to-date");
@@ -133,6 +143,20 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
[isDesktopApp],
);
useEffect(() => {
if (!isDesktopApp || status !== "pending") {
return undefined;
}
const intervalId = setInterval(() => {
void checkForUpdates({ silent: true });
}, PENDING_RECHECK_MS);
return () => {
clearInterval(intervalId);
};
}, [checkForUpdates, isDesktopApp, status]);
const installUpdate = useCallback(async () => {
if (!isDesktopApp) {
return null;

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveFeatureValues } from "./feature-preferences";
describe("feature-preferences", () => {
const features = [
{
type: "toggle" as const,
id: "fast_mode",
label: "Fast",
value: false,
},
{
type: "toggle" as const,
id: "plan_mode",
label: "Plan",
value: false,
},
];
it("restores persisted values for available features", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
unknown_feature: true,
},
localFeatureValues: {},
}),
).toEqual({
fast_mode: true,
});
});
it("prefers local values over persisted values", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
plan_mode: false,
},
localFeatureValues: {
fast_mode: false,
},
}),
).toEqual({
fast_mode: false,
plan_mode: false,
});
});
});

View File

@@ -0,0 +1,60 @@
import type { AgentFeature } from "@server/server/agent/agent-sdk-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
export function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
export function resolveFeatureValues(args: {
features: AgentFeature[];
persistedFeatureValues: Record<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const feature of args.features) {
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
next[feature.id] = args.localFeatureValues[feature.id];
continue;
}
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
next[feature.id] = args.persistedFeatureValues[feature.id];
}
}
return next;
}

View File

@@ -93,6 +93,7 @@ type UseAgentFormStateResult = {
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
invalidateProviderModels: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
@@ -375,6 +376,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isFetching: snapshotIsFetching,
error: snapshotError,
refresh: refreshSnapshot,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
@@ -648,33 +650,36 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshSnapshot();
}, [refreshSnapshot]);
const invalidateProviderModels = useCallback(() => {
invalidateSnapshot();
}, [invalidateSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
await updatePreferences(nextPreferences);
await updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: formState.provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
}),
);
}, [
availableModels,
formState.model,
formState.modeId,
formState.provider,
formState.thinkingOptionId,
preferences?.providerPreferences,
updatePreferences,
]);
@@ -715,6 +720,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading,
modelError,
refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
@@ -746,6 +752,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading,
modelError,
refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,

View File

@@ -0,0 +1,164 @@
import { useState, useCallback, useMemo } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {
client: DaemonClient | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
currentBranchName: string | null;
isGitCheckout: boolean;
isConnected: boolean;
toast: ToastApi;
queryClient: QueryClient;
}
interface UseBranchSwitcherResult {
branchOptions: ComboboxOption[];
isOpen: boolean;
setIsOpen: (open: boolean) => void;
handleBranchSelect: (branchId: string) => void;
invalidateStashAndCheckout: () => Promise<void>;
}
export function useBranchSwitcher({
client,
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
isGitCheckout,
isConnected,
toast,
queryClient,
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
const [isOpen, setIsOpen] = useState(false);
const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getBranchSuggestions({
cwd: normalizedWorkspaceId,
limit: 200,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.branches ?? [];
},
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
retry: false,
staleTime: 15_000,
});
const branchOptions = useMemo<ComboboxOption[]>(() => {
const branches = branchSuggestionsQuery.data ?? [];
return branches.map((name) => ({ id: name, label: name }));
}, [branchSuggestionsQuery.data]);
const stashListQueryKey = useMemo(
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
[normalizedServerId, normalizedWorkspaceId],
);
const invalidateStashAndCheckout = useCallback(async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
}),
]);
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
const stashAndSwitch = useCallback(
async (branchId: string) => {
if (!client) return;
const shouldStash = await confirmDialog({
title: "Uncommitted changes",
message:
"You have uncommitted changes. Stash them before switching branches?",
confirmLabel: "Stash & Switch",
cancelLabel: "Cancel",
});
if (!shouldStash) return;
try {
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
branch: currentBranchName ?? undefined,
});
if (stashPayload.error) {
toast.error(stashPayload.error.message);
return;
}
await invalidateStashAndCheckout();
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
if (switchPayload.error) {
toast.error(switchPayload.error.message);
return;
}
await invalidateStashAndCheckout();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
}
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
);
const handleBranchSelect = useCallback(
(branchId: string) => {
if (branchId === currentBranchName) return;
void (async () => {
if (!client) return;
try {
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
if (payload.error) {
// If the error is about uncommitted changes, offer the stash dialog
if (payload.error.message.toLowerCase().includes("uncommitted")) {
await stashAndSwitch(branchId);
return;
}
toast.error(payload.error.message);
return;
}
// Success — refresh and check for stashes on the target branch
await invalidateStashAndCheckout();
try {
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
if (targetStash) {
const shouldRestore = await confirmDialog({
title: "Restore stashed changes?",
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
confirmLabel: "Restore",
cancelLabel: "Later",
});
if (shouldRestore) {
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
if (popPayload.error) {
toast.error(popPayload.error.message);
} else {
toast.show("Stashed changes restored");
}
await invalidateStashAndCheckout();
}
}
} catch {
// Non-critical — user can still restore on next branch switch
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
}
})();
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
);
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
}

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
@@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutStatusResponse } from "@server/shared/messages";
@@ -32,7 +32,7 @@ function fetchCheckoutStatus(
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);

View File

@@ -1,50 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
applyFeatureValues,
pruneFeatureValues,
resolveFeatureValues,
} from "./feature-preferences";
type DraftFeatureConfig = Pick<
AgentSessionConfig,
@@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: {
thinkingOptionId: string | null | undefined;
}) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
@@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: {
return payload.features ?? [];
},
});
const availableFeatures = featuresQuery.data ?? [];
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [featureValues, featuresQuery.data]);
return applyFeatureValues(availableFeatures, featureValues);
}, [availableFeatures, featureValues]);
useEffect(() => {
const next = pruneFeatureValues(featureValues, features);
if (next !== featureValues) {
setFeatureValues(next);
setLocalFeatureValues({});
}, [provider]);
useEffect(() => {
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) {
setLocalFeatureValues(next);
}
}, [featureValues, features]);
}, [availableFeatures, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
}, []);
void updatePreferences(
(current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
}, [provider, updatePreferences]);
return {
features,

View File

@@ -59,6 +59,41 @@ describe("mergeProviderPreferences", () => {
},
});
});
it("merges feature values without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
featureValues: {
fast_mode: true,
},
},
},
},
provider: "codex",
updates: {
featureValues: {
plan_mode: true,
},
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
featureValues: {
fast_mode: true,
plan_mode: true,
},
},
},
});
});
});
describe("favorite model preferences", () => {

View File

@@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
@@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
updatePreferences: (
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
}
export function mergeProviderPreferences(args: {
@@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
@@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
@@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback(
async (updates: Partial<FormPreferences>) => {
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next = { ...prev, ...updates };
const next =
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},

View File

@@ -17,6 +17,7 @@ interface UseProvidersSnapshotResult {
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
invalidate: () => void;
}
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
@@ -66,6 +67,10 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
void client.refreshProvidersSnapshot();
}, [client]);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
@@ -73,6 +78,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,
invalidate,
};
}

View File

@@ -6,14 +6,22 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
export interface AppSettings {
theme: "dark" | "light" | "auto";
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
};
export interface UseAppSettingsReturn {
@@ -74,6 +82,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed };
}

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ import {
} from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images";
@@ -226,6 +226,7 @@ function DraftAgentScreenContent({
isModelLoading,
modelError,
refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser,
persistFormPreferences,
} = useAgentFormState({
@@ -235,7 +236,7 @@ function DraftAgentScreenContent({
isCreateFlow: true,
onlineServerIds,
});
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1268,6 +1269,7 @@ function DraftAgentScreenContent({
onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onModelSelectorOpen: invalidateProviderModels,
disabled: isSubmitting,
}}
/>

View File

@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -17,7 +17,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
useEffect(() => {
if (!isCompactLayout) {

View File

@@ -10,12 +10,12 @@ import {
Sun,
Moon,
Monitor,
ChevronDown,
Globe,
Settings,
RotateCw,
Trash2,
Server,
Palette,
Keyboard,
Stethoscope,
Info,
@@ -24,7 +24,8 @@ import {
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
import { THEME_SWATCHES, type ThemeName } from "@/styles/theme";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -42,13 +43,13 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
@@ -65,7 +66,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { isCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
@@ -77,7 +78,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
type SettingsSectionId =
| "hosts"
| "appearance"
| "general"
| "shortcuts"
| "integrations"
| "providers"
@@ -96,21 +97,21 @@ interface SettingsSectionDef {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "general", label: "General", icon: Settings },
{ id: "permissions", label: "Permissions", icon: Shield },
];
if (context.isDesktopApp) {
sections.push(
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
{ id: "providers", label: "Providers", icon: Blocks },
);
}
sections.push(
{ id: "providers", label: "Providers", icon: Blocks },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
);
@@ -204,10 +205,6 @@ interface HostsSectionProps {
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
setIsPasteLinkVisible: (visible: boolean) => void;
pendingNameHost: { serverId: string; hostname: string | null } | null;
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
pendingNameHostname: string | null;
renameHost: (serverId: string, label: string) => Promise<void>;
pendingRemoveHost: HostProfile | null;
setPendingRemoveHost: (host: HostProfile | null) => void;
isRemovingHost: boolean;
@@ -290,38 +287,14 @@ function HostsSection(props: HostsSectionProps) {
visible={props.isDirectHostVisible}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
<PairLinkModal
visible={props.isPasteLinkVisible}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
{props.pendingNameHost ? (
<NameHostModal
visible
serverId={props.pendingNameHost.serverId}
hostname={props.pendingNameHostname}
onSkip={() => props.setPendingNameHost(null)}
onSave={(label) => {
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
props.setPendingNameHost(null);
});
}}
/>
) : null}
{props.pendingRemoveHost ? (
<AdaptiveModalSheet
title="Remove host"
@@ -386,41 +359,119 @@ function HostsSection(props: HostsSectionProps) {
);
}
interface AppearanceSectionProps {
interface GeneralSectionProps {
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
switch (theme) {
case "light":
return <Sun size={size} color={color} />;
case "dark":
return <Moon size={size} color={color} />;
case "auto":
return <Monitor size={size} color={color} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
}
}
function ThemeSwatch({ color, size }: { color: string; size: number }) {
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.15)",
}}
/>
);
}
const THEME_LABELS: Record<AppSettings["theme"], string> = {
light: "Light",
dark: "Dark",
zinc: "Zinc",
midnight: "Midnight",
claude: "Claude",
ghostty: "Ghostty",
auto: "System",
};
function GeneralSection({
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
<Text style={settingsStyles.sectionTitle}>General</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.themeTrigger,
pressed && { opacity: 0.85 },
]}
>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>
{THEME_LABELS[settings.theme]}
</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{(["light", "dark", "auto"] as const).map((t) => (
<DropdownMenuItem
key={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
<DropdownMenuItem
key={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Default send</Text>
<Text style={styles.audioRowSubtitle}>
What happens when you press Enter while the agent is running
</Text>
</View>
<SegmentedControl
size="sm"
hideLabels={Platform.OS !== "web"}
value={settings.theme}
onValueChange={handleThemeChange}
value={settings.sendBehavior}
onValueChange={handleSendBehaviorChange}
options={[
{
value: "light",
label: "Light",
icon: ({ color, size }) => <Sun size={size} color={color} />,
},
{
value: "dark",
label: "Dark",
icon: ({ color, size }) => <Moon size={size} color={color} />,
},
{
value: "auto",
label: "System",
icon: ({ color, size }) => <Monitor size={size} color={color} />,
},
{ value: "interrupt", label: "Interrupt" },
{ value: "queue", label: "Queue" },
]}
/>
</View>
@@ -614,7 +665,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
interface SettingsSectionContentProps {
sectionId: SettingsSectionId;
hostsProps: HostsSectionProps;
appearanceProps: AppearanceSectionProps;
generalProps: GeneralSectionProps;
providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps;
@@ -626,7 +677,7 @@ interface SettingsSectionContentProps {
function SettingsSectionContent({
sectionId,
hostsProps,
appearanceProps,
generalProps,
providersProps,
diagnosticsProps,
aboutProps,
@@ -637,8 +688,8 @@ function SettingsSectionContent({
switch (sectionId) {
case "hosts":
return <HostsSection {...hostsProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "general":
return <GeneralSection {...generalProps} />;
case "shortcuts":
return <KeyboardShortcutsSection />;
case "providers":
@@ -815,7 +866,7 @@ function DesktopAppUpdateRow() {
<Text style={styles.aboutHintText}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.aboutHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
@@ -863,10 +914,6 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
@@ -884,19 +931,6 @@ export default function SettingsScreen() {
const editingDaemonLive = editingServerId
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
: null;
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
return () => {
@@ -1003,6 +1037,13 @@ export default function SettingsScreen() {
[updateSettings],
);
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -1032,7 +1073,7 @@ export default function SettingsScreen() {
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
const isCompactLayout = isCompactFormFactor();
const isCompactLayout = useIsCompactFormFactor();
const sections = getSettingsSections({ isDesktopApp });
const hostsProps: HostsSectionProps = {
@@ -1049,10 +1090,6 @@ export default function SettingsScreen() {
goBackToAddConnectionMethods,
setIsDirectHostVisible,
setIsPasteLinkVisible,
pendingNameHost,
setPendingNameHost,
pendingNameHostname,
renameHost,
pendingRemoveHost,
setPendingRemoveHost,
isRemovingHost,
@@ -1069,9 +1106,10 @@ export default function SettingsScreen() {
isMountedRef,
};
const appearanceProps: AppearanceSectionProps = {
const generalProps: GeneralSectionProps = {
settings,
handleThemeChange,
handleSendBehaviorChange,
};
const diagnosticsProps: DiagnosticsSectionProps = {
@@ -1092,7 +1130,7 @@ export default function SettingsScreen() {
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps,
appearanceProps,
generalProps,
providersProps,
diagnosticsProps,
aboutProps,
@@ -1827,6 +1865,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
themeTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
},
themeTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
disabled: {
opacity: theme.opacity[50],
},
@@ -1901,6 +1953,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
audioRowSubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: {
flexDirection: "row",
alignItems: "center",

View File

@@ -38,6 +38,28 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
errorScreen: {
position: "relative",
flex: 1,
backgroundColor: theme.colors.surface0,
},
errorScrollView: {
flex: 1,
...(Platform.OS === "web"
? {
overflowX: "auto",
overflowY: "auto",
}
: null),
},
errorScrollContent: {
flexGrow: 1,
alignItems: "center",
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
@@ -255,67 +277,73 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
}
return (
<View style={[styles.container, styles.containerError]}>
<View style={styles.errorScreen}>
<TitlebarDragRegion />
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
<ScrollView
style={styles.errorScrollView}
contentContainerStyle={styles.errorScrollContent}
showsVerticalScrollIndicator
>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
</ScrollView>
</View>
);
}

View File

@@ -80,6 +80,7 @@ export function WorkspaceDraftAgentTab({
availableThinkingOptions,
isModelLoading,
setProviderAndModelFromUser,
invalidateProviderModels,
persistFormPreferences,
} = useAgentFormState({
initialServerId: serverId,
@@ -373,6 +374,7 @@ export function WorkspaceDraftAgentTab({
features: draftFeatures,
onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: () => focusInputRef.current?.(),
onModelSelectorOpen: invalidateProviderModels,
disabled: isSubmitting,
}}
/>

View File

@@ -34,6 +34,7 @@ import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { BranchSwitcher } from "@/components/branch-switcher";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import {
@@ -80,6 +81,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -116,7 +118,7 @@ import {
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -591,7 +593,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const insets = useSafeAreaInsets();
const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast();
const isMobile = isCompactFormFactor();
const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
@@ -759,6 +761,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
const {
branchOptions,
isOpen: isBranchSwitcherOpen,
setIsOpen: setIsBranchSwitcherOpen,
handleBranchSelect,
invalidateStashAndCheckout,
} = useBranchSwitcher({
client,
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
isGitCheckout,
isConnected,
toast,
queryClient,
});
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1948,13 +1968,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</>
) : (
<>
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{workspaceHeader.title}
</Text>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeader.title}
branchOptions={branchOptions}
isOpen={isBranchSwitcherOpen}
onOpenChange={setIsBranchSwitcherOpen}
onBranchSelect={handleBranchSelect}
/>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
@@ -2350,12 +2371,12 @@ const styles = StyleSheet.create((theme) => ({
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
color: theme.colors.diffAddition,
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
color: theme.colors.diffDeletion,
},
newTabActions: {
flexDirection: "row",

View File

@@ -103,6 +103,19 @@ export const baseColors = {
},
} as const;
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
// Diff stat colors — light uses muted tones, dark uses the brighter palette values
const lightDiffColors = {
diffAddition: "#15803d", // green-700 — readable on white without screaming
diffDeletion: "#b91c1c", // red-700
};
const darkDiffColors = {
diffAddition: "#4ade80", // green-400
diffDeletion: "#ef4444", // red-500
};
// Semantic color tokens - Layer-based system
const lightSemanticColors = {
// Surfaces (layers) - shifted one step lighter
@@ -113,10 +126,11 @@ const lightSemanticColors = {
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode)
surfaceWorkspace: "#ffffff", // Workspace main background
// Text
foreground: "#09090b",
foreground: "#1a1a1e",
foregroundMuted: "#71717a",
// Controls
@@ -140,26 +154,28 @@ const lightSemanticColors = {
// Legacy aliases (for gradual migration)
background: "#ffffff",
popover: "#ffffff",
popoverForeground: "#09090b",
popoverForeground: "#1a1a1e",
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
secondaryForeground: "#09090b",
secondaryForeground: "#1a1a1e",
muted: "#f4f4f5",
mutedForeground: "#71717a",
accentBorder: "#ececf1",
input: "#f4f4f5",
ring: "#18181b",
...lightDiffColors,
terminal: {
background: "#ffffff",
foreground: "#09090b",
cursor: "#09090b",
foreground: "#1a1a1e",
cursor: "#1a1a1e",
cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#09090b",
selectionForeground: "#1a1a1e",
black: "#09090b",
black: "#1a1a1e",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
@@ -179,80 +195,196 @@ const lightSemanticColors = {
},
} as const;
const darkSemanticColors = {
// Surfaces (layers) — subtle teal tint
surface0: "#181B1A", // App background
surface1: "#1E2120", // Subtle hover
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
// ---------------------------------------------------------------------------
// Dark theme variant builder
// ---------------------------------------------------------------------------
// Text
foreground: "#fafafa",
interface DarkThemeConfig {
surface0: string;
surface1: string;
surface2: string;
surface3: string;
surface4: string;
surfaceDiffEmpty: string;
surfaceSidebar: string;
surfaceSidebarHover: string;
foregroundMuted: string;
scrollbarHandle: string;
border: string;
borderAccent: string;
accent: string;
accentBright: string;
}
const darkTerminalAnsi = {
red: "#e07070",
green: "#5dba80",
yellow: "#d4a44a",
blue: "#6a9de0",
magenta: "#b07ad0",
cyan: "#4aabb8",
white: "#d4d4d8",
brightRed: "#e89090",
brightGreen: "#7ecf9a",
brightYellow: "#e0be6e",
brightBlue: "#8ab4e8",
brightMagenta: "#c49ae0",
brightCyan: "#6ec2cc",
brightWhite: "#f0f0f2",
} as const;
function buildDarkSemanticColors(tint: DarkThemeConfig) {
return {
surface0: tint.surface0,
surface1: tint.surface1,
surface2: tint.surface2,
surface3: tint.surface3,
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surfaceSidebarHover,
surfaceWorkspace: tint.surface1,
foreground: "#fafafa",
foregroundMuted: tint.foregroundMuted,
scrollbarHandle: tint.scrollbarHandle,
border: tint.border,
borderAccent: tint.borderAccent,
accent: tint.accent,
accentBright: tint.accentBright,
accentForeground: "#ffffff",
destructive: "#ef4444",
destructiveForeground: "#ffffff",
success: tint.accent,
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: tint.surface0,
popover: tint.surface2,
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: tint.surface0,
secondary: tint.surface2,
secondaryForeground: "#fafafa",
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: "#d4d4d8",
...darkDiffColors,
terminal: {
background: tint.surface0,
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: tint.surface0,
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
black: tint.surfaceSidebar,
...darkTerminalAnsi,
brightBlack: tint.surface3,
},
};
}
// ---------------------------------------------------------------------------
// Dark tint definitions
// ---------------------------------------------------------------------------
// Paseo — subtle teal-green tint (default)
const paseoDarkColors = buildDarkSemanticColors({
surface0: "#181B1A",
surface1: "#1E2120",
surface2: "#272A29",
surface3: "#434645",
surface4: "#595B5B",
surfaceDiffEmpty: "#252827",
surfaceSidebar: "#141716",
surfaceSidebarHover: "#1c1f1e",
foregroundMuted: "#A1A5A4",
// Controls
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders
scrollbarHandle: "#717574",
border: "#252B2A",
borderAccent: "#2F3534",
// Brand
accent: "#20744A",
accentBright: "#7ccba0",
accentForeground: "#ffffff",
});
// Semantic
destructive: "#ef4444",
destructiveForeground: "#ffffff",
success: "#20744A",
successForeground: "#ffffff",
// Zinc — neutral gray, no tint
const zincDarkColors = buildDarkSemanticColors({
surface0: "#18181b",
surface1: "#1f1f22",
surface2: "#27272a",
surface3: "#3f3f46",
surface4: "#52525b",
surfaceDiffEmpty: "#242427",
surfaceSidebar: "#131316",
surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa",
scrollbarHandle: "#71717a",
border: "#27272a",
borderAccent: "#303036",
accent: "#20744A",
accentBright: "#7ccba0",
});
// Legacy aliases (for gradual migration)
background: "#181B1A",
popover: "#272A29",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#181B1A",
secondary: "#272A29",
secondaryForeground: "#fafafa",
muted: "#272A29",
mutedForeground: "#A1A5A4",
accentBorder: "#2F3534",
input: "#272A29",
ring: "#d4d4d8",
// Midnight — subtle blue tint
const midnightDarkColors = buildDarkSemanticColors({
surface0: "#161820",
surface1: "#1c1e27",
surface2: "#252731",
surface3: "#3c3e4c",
surface4: "#535564",
surfaceDiffEmpty: "#222430",
surfaceSidebar: "#121420",
surfaceSidebarHover: "#1a1c28",
foregroundMuted: "#9a9db0",
scrollbarHandle: "#6b6e82",
border: "#242636",
borderAccent: "#2e3040",
accent: "#3b6fcf",
accentBright: "#7eaaeb",
});
terminal: {
background: "#181B1A",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#181B1A",
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
// Claude — warm neutral with subtle orange undertone
const claudeDarkColors = buildDarkSemanticColors({
surface0: "#1f1f1e",
surface1: "#262523",
surface2: "#2f2d2b",
surface3: "#4a4745",
surface4: "#605d5b",
surfaceDiffEmpty: "#2a2826",
surfaceSidebar: "#1a1918",
surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5",
scrollbarHandle: "#78746f",
border: "#2c2a27",
borderAccent: "#36332f",
accent: "#d97757",
accentBright: "#e89a7f",
});
black: "#141716",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#434645",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#ffffff",
},
} as const;
// Ghostty — blue-tinted dark based on Ghostty default background
const ghosttyDarkColors = buildDarkSemanticColors({
surface0: "#282c34",
surface1: "#2f333d",
surface2: "#383c48",
surface3: "#4a4f5e",
surface4: "#5b6175",
surfaceDiffEmpty: "#323643",
surfaceSidebar: "#21252d",
surfaceSidebarHover: "#292d36",
foregroundMuted: "#c8ccd8",
scrollbarHandle: "#a0a4b2",
border: "#353a47",
borderAccent: "#3f4454",
accent: "#89b4fa",
accentBright: "#b4d0fc",
});
const commonTheme = {
spacing: {
@@ -323,35 +455,45 @@ const commonTheme = {
},
} as const;
export const darkTheme = {
colorScheme: "dark" as const,
colors: {
...darkSemanticColors,
palette: baseColors,
const darkShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
...commonTheme,
} as const;
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
return {
colorScheme: "dark" as const,
colors: {
...semanticColors,
palette: baseColors,
},
shadow: darkShadow,
...commonTheme,
} as const;
}
export const darkTheme = buildDarkTheme(paseoDarkColors);
export const darkZincTheme = buildDarkTheme(zincDarkColors);
export const darkMidnightTheme = buildDarkTheme(midnightDarkColors);
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors);
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors);
export const lightTheme = {
colorScheme: "light" as const,
colors: {
@@ -386,3 +528,23 @@ export const theme = darkTheme;
// Export a union type that works for both themes
export type Theme = typeof darkTheme | typeof lightTheme;
type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty";
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
light: "light",
dark: "dark",
zinc: "darkZinc",
midnight: "darkMidnight",
claude: "darkClaude",
ghostty: "darkGhostty",
};
export const THEME_SWATCHES: Record<ThemeName, string> = {
light: "#ffffff",
dark: "#2D8B62",
zinc: "#808080",
midnight: "#4A6BA8",
claude: "#D97757",
ghostty: "#8caaee",
};

View File

@@ -1,11 +1,21 @@
import { StyleSheet } from "react-native-unistyles";
// import { UnistylesRuntime } from "react-native-unistyles";
import { lightTheme, darkTheme } from "./theme";
import {
lightTheme,
darkTheme,
darkZincTheme,
darkMidnightTheme,
darkClaudeTheme,
darkGhosttyTheme,
} from "./theme";
StyleSheet.configure({
themes: {
light: lightTheme,
dark: darkTheme,
darkZinc: darkZincTheme,
darkMidnight: darkMidnightTheme,
darkClaude: darkClaudeTheme,
darkGhostty: darkGhosttyTheme,
},
breakpoints: {
xs: 0,
@@ -23,6 +33,10 @@ StyleSheet.configure({
type AppThemes = {
light: typeof lightTheme;
dark: typeof darkTheme;
darkZinc: typeof darkZincTheme;
darkMidnight: typeof darkMidnightTheme;
darkClaude: typeof darkClaudeTheme;
darkGhostty: typeof darkGhosttyTheme;
};
type AppBreakpoints = {
@@ -37,5 +51,3 @@ declare module "react-native-unistyles" {
export interface UnistylesThemes extends AppThemes {}
export interface UnistylesBreakpoints extends AppBreakpoints {}
}
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildSplitDiffRows } from "./diff-layout";
import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
@@ -79,3 +79,76 @@ describe("buildSplitDiffRows", () => {
});
});
});
describe("buildUnifiedDiffLines", () => {
it("computes line numbers per line type within a hunk", () => {
const lines = buildUnifiedDiffLines(
makeFile([
{ type: "header", content: "@@ -10,3 +10,4 @@" },
{ type: "context", content: "before" },
{ type: "add", content: "inserted" },
{ type: "remove", content: "removed" },
{ type: "context", content: "after" },
]),
);
expect(
lines.map(({ line, lineNumber }) => ({
type: line.type,
lineNumber,
content: line.content,
})),
).toEqual([
{ type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" },
{ type: "context", lineNumber: 10, content: "before" },
{ type: "add", lineNumber: 11, content: "inserted" },
{ type: "remove", lineNumber: 11, content: "removed" },
{ type: "context", lineNumber: 12, content: "after" },
]);
});
it("restarts numbering at each hunk boundary", () => {
const file: ParsedDiffFile = {
path: "example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
status: "ok",
hunks: [
{
oldStart: 75,
oldCount: 2,
newStart: 75,
newCount: 3,
lines: [
{ type: "header", content: "@@ -75,2 +75,3 @@" },
{ type: "context", content: "first" },
{ type: "add", content: "inserted" },
{ type: "context", content: "second" },
],
},
{
oldStart: 165,
oldCount: 2,
newStart: 166,
newCount: 2,
lines: [
{ type: "header", content: "@@ -165,2 +166,2 @@" },
{ type: "context", content: "third" },
{ type: "context", content: "fourth" },
],
},
],
};
const lines = buildUnifiedDiffLines(file);
expect(lines[0]?.lineNumber).toBeNull();
expect(lines[1]?.lineNumber).toBe(75);
expect(lines[3]?.lineNumber).toBe(77);
expect(lines[4]?.lineNumber).toBeNull();
expect(lines[5]?.lineNumber).toBe(166);
expect(lines[6]?.lineNumber).toBe(167);
});
});

View File

@@ -7,6 +7,12 @@ export interface SplitDiffDisplayLine {
lineNumber: number | null;
}
export interface UnifiedDiffDisplayLine {
key: string;
line: DiffLine;
lineNumber: number | null;
}
export type SplitDiffRow =
| {
kind: "header";
@@ -61,6 +67,39 @@ function toDisplayLine(input: {
};
}
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
const lines: UnifiedDiffDisplayLine[] = [];
for (const [hunkIndex, hunk] of file.hunks.entries()) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
for (const [lineIndex, line] of hunk.lines.entries()) {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo += 1;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo += 1;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo += 1;
newLineNo += 1;
}
lines.push({
key: `${hunkIndex}-${lineIndex}`,
line,
lineNumber,
});
}
}
return lines;
}
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering";
describe("diff-rendering", () => {
it("keeps header gutters tall even when they do not show a line number", () => {
expect(formatDiffGutterText(null)).toBe(" ");
expect(formatDiffGutterText(82)).toBe("82");
});
it("keeps empty split cells tall even when they have no visible content", () => {
expect(formatDiffContentText(undefined)).toBe(" ");
expect(formatDiffContentText("")).toBe(" ");
expect(formatDiffContentText("const value = 1;")).toBe("const value = 1;");
});
it("treats empty highlighted token rows as blank lines instead of visible content", () => {
expect(hasVisibleDiffTokens(undefined)).toBe(false);
expect(hasVisibleDiffTokens([])).toBe(false);
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
});
});

View File

@@ -0,0 +1,16 @@
interface HighlightLikeToken {
text: string;
}
// Preserve row height when a gutter or diff cell is intentionally blank.
export function formatDiffGutterText(lineNumber: number | null): string {
return lineNumber == null ? " " : String(lineNumber);
}
export function formatDiffContentText(content: string | null | undefined): string {
return content && content.length > 0 ? content : " ";
}
export function hasVisibleDiffTokens(tokens: HighlightLikeToken[] | null | undefined): boolean {
return Boolean(tokens?.some((token) => token.text.length > 0));
}

View File

@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
).toBe("attention");
});
it("treats initializing agents as running", () => {
it("treats initializing agents as done", () => {
expect(
deriveSidebarStateBucket({
status: "initializing",
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
requiresAttention: false,
attentionReason: null,
}),
).toBe("running");
).toBe("done");
});
});

View File

@@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: {
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running" || input.status === "initializing") {
if (input.status === "running") {
return "running";
}
if (input.requiresAttention) {

View File

@@ -52,7 +52,7 @@ describe("terminal key helpers", () => {
});
});
it("intercepts special keys and modifier combos", () => {
it("only intercepts when pending modifiers are active", () => {
expect(
shouldInterceptDomTerminalKey({
key: "Escape",
@@ -60,7 +60,7 @@ describe("terminal key helpers", () => {
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
).toBe(false);
expect(
shouldInterceptDomTerminalKey({
key: "c",
@@ -68,15 +68,23 @@ describe("terminal key helpers", () => {
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
).toBe(false);
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
pendingModifiers: { ctrl: true, shift: false, alt: false },
}),
).toBe(false);
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
key: "Escape",
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: true },
}),
).toBe(true);
});
it("detects pending modifier state", () => {

View File

@@ -88,12 +88,7 @@ export function shouldInterceptDomTerminalKey(args: {
altKey: boolean;
pendingModifiers: PendingTerminalModifiers;
}): boolean {
return (
args.key.length > 1 ||
args.ctrlKey ||
args.altKey ||
hasPendingTerminalModifiers(args.pendingModifiers)
);
return hasPendingTerminalModifiers(args.pendingModifiers);
}
export function mergeTerminalModifiers(args: {

View File

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

View File

@@ -1,19 +1,19 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": {
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"dev": "./scripts/dev.sh",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@getpaseo/cli": "0.1.52",
"@getpaseo/server": "0.1.52",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

28
packages/desktop/scripts/dev.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
# Build the Electron main process
npm run build:main
# Get a random available port for Metro
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
export EXPO_PORT
echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev"
echo "══════════════════════════════════════════════════════"
echo " Metro: http://localhost:${EXPO_PORT}"
echo "══════════════════════════════════════════════════════"
# Launch Metro + Electron together, kill both on exit
"$ROOT_DIR/node_modules/.bin/concurrently" \
--kill-others \
--names "metro,electron" \
--prefix-colors "magenta,cyan" \
"cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"

View File

@@ -7,6 +7,7 @@ import { autoUpdater, type UpdateInfo } from "electron-updater";
export type AppUpdateCheckResult = {
hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string;
latestVersion: string;
body: string | null;
@@ -24,19 +25,84 @@ export type AppUpdateInstallResult = {
// ---------------------------------------------------------------------------
let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let autoUpdaterConfigured = false;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
function configureAutoUpdater(): void {
// Don't auto-download — the user triggers install explicitly.
autoUpdater.autoDownload = false;
// Download updates in the background and only prompt once they are ready to install.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true;
if (autoUpdaterConfigured) {
return;
}
autoUpdaterConfigured = true;
autoUpdater.on("update-available", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
});
autoUpdater.on("update-downloaded", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = info.version;
downloading = false;
});
autoUpdater.on("update-not-available", () => {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
});
autoUpdater.on("error", (error) => {
downloading = false;
console.error("[auto-updater] Updater event failed:", error);
});
}
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
}
function buildCheckResult(input: {
currentVersion: string;
hasUpdate: boolean;
readyToInstall: boolean;
info?: UpdateInfo | null;
}): AppUpdateCheckResult {
const { currentVersion, hasUpdate, readyToInstall, info } = input;
return {
hasUpdate,
readyToInstall,
currentVersion,
latestVersion: info?.version ?? currentVersion,
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
};
}
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Use a short delay to allow the renderer to receive the response.
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
}
// ---------------------------------------------------------------------------
@@ -45,28 +111,34 @@ function configureAutoUpdater(): void {
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) {
return {
hasUpdate: false,
return buildCheckResult({
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
hasUpdate: false,
readyToInstall: false,
});
}
configureAutoUpdater();
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try {
const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) {
return {
hasUpdate: false,
return buildCheckResult({
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
hasUpdate: false,
readyToInstall: false,
});
}
const info = result.updateInfo;
@@ -75,24 +147,31 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
if (hasUpdate) {
cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
}
return {
hasUpdate,
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
return buildCheckResult({
currentVersion,
latestVersion,
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info.releaseDate === "string" ? info.releaseDate : null,
};
hasUpdate: false,
readyToInstall: false,
});
} catch (error) {
console.error("[auto-updater] Failed to check for updates:", error);
return {
hasUpdate: false,
return buildCheckResult({
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
hasUpdate: false,
readyToInstall: false,
});
}
}
@@ -108,14 +187,6 @@ export async function downloadAndInstallUpdate(
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update already in progress.",
};
}
if (!cachedUpdateInfo) {
return {
installed: false,
@@ -126,24 +197,35 @@ export async function downloadAndInstallUpdate(
configureAutoUpdater();
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
scheduleQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true;
try {
await autoUpdater.downloadUpdate();
// quitAndInstall restarts the app with the new version.
// Use a short delay to allow the renderer to receive the response.
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
downloadedUpdateVersion = readyVersion;
downloading = false;
scheduleQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: cachedUpdateInfo.version,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
} catch (error) {

View File

@@ -8,6 +8,7 @@ inheritLoginShellEnv();
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import {
@@ -38,6 +39,37 @@ const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
app.setName("Paseo");
// In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null;
if (!app.isPackaged) {
try {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
timeout: 3000,
}).trim();
devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
const commonDir = path.resolve(
topLevel,
execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: topLevel,
encoding: "utf-8",
timeout: 3000,
}).trim(),
);
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
if (isWorktree) {
app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`));
log.info("[worktree] isolated userData for worktree:", devWorktreeName);
} else {
devWorktreeName = null;
}
} catch {
devWorktreeName = null;
}
}
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
// Must run before app.whenReady().
@@ -127,7 +159,9 @@ async function createMainWindow(): Promise<void> {
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
const mainWindow = new BrowserWindow({
title,
width: 1200,
height: 800,
show: false,
@@ -144,6 +178,10 @@ async function createMainWindow(): Promise<void> {
},
});
if (devWorktreeName) {
app.dock?.setBadge(devWorktreeName);
}
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"type": "module",
"publishConfig": {
"access": "public"

View File

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

View File

@@ -18,3 +18,6 @@ PASEO_LISTEN=127.0.0.1:6767
# Debug recordings (dictation + STT input + TTS output)
# When enabled, recordings are saved under `${cwd}/.debug/recordings/`
PASEO_DICTATION_DEBUG=1
# Enable verbose Claude SDK stream logging (trace-level per-token logs)
PASEO_CLAUDE_DEBUG=1

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.51-rc.1",
"version": "0.1.52",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.52",
"@getpaseo/relay": "0.1.52",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",

View File

@@ -28,6 +28,10 @@ import type {
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
CheckoutSwitchBranchResponse,
StashSaveResponse,
StashPopResponse,
StashListResponse,
ValidateBranchResponse,
BranchSuggestionsResponse,
DirectorySuggestionsResponse,
@@ -217,6 +221,10 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
type StashSavePayload = StashSaveResponse["payload"];
type StashPopPayload = StashPopResponse["payload"];
type StashListPayload = StashListResponse["payload"];
type ValidateBranchPayload = ValidateBranchResponse["payload"];
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
@@ -2374,6 +2382,74 @@ export class DaemonClient {
});
}
async checkoutSwitchBranch(
cwd: string,
branch: string,
requestId?: string,
): Promise<CheckoutSwitchBranchPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_switch_branch_request",
cwd,
branch,
},
responseType: "checkout_switch_branch_response",
timeout: 30000,
});
}
async stashSave(
cwd: string,
options?: { branch?: string },
requestId?: string,
): Promise<StashSavePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_save_request",
cwd,
branch: options?.branch,
},
responseType: "stash_save_response",
timeout: 30000,
});
}
async stashPop(
cwd: string,
stashIndex: number,
requestId?: string,
): Promise<StashPopPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_pop_request",
cwd,
stashIndex,
},
responseType: "stash_pop_response",
timeout: 30000,
});
}
async stashList(
cwd: string,
options?: { paseoOnly?: boolean },
requestId?: string,
): Promise<StashListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_list_request",
cwd,
paseoOnly: options?.paseoOnly,
},
responseType: "stash_list_response",
timeout: 10000,
});
}
async getPaseoWorktreeList(
input: { cwd?: string; repoRoot?: string },
requestId?: string,

View File

@@ -415,10 +415,39 @@ describe("transformPiModels", () => {
});
describe("ACPAgentSession slash commands", () => {
test("caches ACP available commands for listCommands", async () => {
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
const session = createSession();
expect(await session.listCommands()).toEqual([]);
await expect(session.listCommands()).resolves.toEqual([]);
});
test("waits for async available_commands_update when enabled", async () => {
const session = new ACPAgentSession(
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
},
);
const listCommandsPromise = session.listCommands();
(session as any).translateSessionUpdate({
sessionUpdate: "available_commands_update",
@@ -434,6 +463,19 @@ describe("ACPAgentSession slash commands", () => {
],
});
expect(await listCommandsPromise).toEqual([
{
name: "research_codebase",
description: "Search the workspace for relevant files",
argumentHint: "",
},
{
name: "create_plan",
description: "Draft a plan for the requested work",
argumentHint: "",
},
]);
expect(await session.listCommands()).toEqual([
{
name: "research_codebase",

View File

@@ -125,6 +125,8 @@ type ACPAgentClientOptions = {
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
};
type ACPAgentSessionOptions = {
@@ -144,6 +146,8 @@ type ACPAgentSessionOptions = {
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
};
type SpawnedACPProcess = {
@@ -302,6 +306,8 @@ export class ACPAgentClient implements AgentClient {
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly waitForInitialCommands: boolean;
private readonly initialCommandsWaitTimeoutMs: number;
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
@@ -314,6 +320,8 @@ export class ACPAgentClient implements AgentClient {
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
}
async createSession(
@@ -335,6 +343,8 @@ export class ACPAgentClient implements AgentClient {
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
},
);
await session.initializeNewSession();
@@ -375,6 +385,8 @@ export class ACPAgentClient implements AgentClient {
capabilities: this.capabilities,
handle,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
});
await session.initializeResumedSession();
return session;
@@ -617,6 +629,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -645,6 +661,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentModel = config.model ?? null;
this.thinkingOptionId = config.thinkingOptionId ?? null;
this.currentTitle = config.title ?? null;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
}
get id(): string | null {
@@ -876,7 +894,59 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
}
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
this.commandsReadyDeferred = { promise, resolve };
}
private settleCommandsReady(): void {
if (this.commandsReadySettled) {
return;
}
this.commandsReadySettled = true;
this.commandsReadyDeferred?.resolve();
this.commandsReadyDeferred = null;
}
private async waitForCommandsReady(): Promise<void> {
const deferred = this.commandsReadyDeferred;
if (!deferred) {
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
deferred.promise,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.cachedCommands.length > 0) {
return this.cachedCommands;
}
if (!this.waitForInitialCommands || this.closed) {
return this.cachedCommands;
}
this.ensureCommandsReadyDeferred();
await this.waitForCommandsReady();
this.settleCommandsReady();
return this.cachedCommands;
}
@@ -1046,6 +1116,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
this.closed = true;
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
@@ -1392,6 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
description: command.description,
argumentHint: "",
}));
this.settleCommandsReady();
return [];
default:
return [];

View File

@@ -1047,6 +1047,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
};
}
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
export class ClaudeAgentClient implements AgentClient {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -2454,15 +2456,17 @@ class ClaudeAgentSession implements AgentSession {
while (!this.closed && this.query === activeQuery) {
try {
for await (const message of activeQuery) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
},
"Claude query pump: raw SDK message",
);
if (claudeDebug) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
},
"Claude query pump: raw SDK message",
);
}
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) {
return;
@@ -2538,14 +2542,16 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
if (claudeDebug) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
}
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,

View File

@@ -315,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode");
});
describe("OpenCode adapter context-window normalization", () => {
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
{ type: "text", text: "Describe this image." },
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
]),
).toEqual([
{ type: "text", text: "Describe this image." },
{
type: "file",
mime: "image/png",
filename: "attachment-1.png",
url: "data:image/png;base64,YWJjMTIz",
},
]);
});
test("preserves provider catalog context limit in model metadata", () => {
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
{ id: "openai", name: "OpenAI" },

View File

@@ -4,8 +4,10 @@ import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
type OpencodeClient,
type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
@@ -494,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
].some((value) => typeof value === "number" && Number.isFinite(value));
}
function getOpenCodeAttachmentExtension(mimeType: string): string {
switch (mimeType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
case "image/svg+xml":
return "svg";
default:
return "bin";
}
}
function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } {
const match = data.match(/^data:([^;,]+);base64,(.+)$/);
if (match) {
return {
mimeType: match[1] ?? mimeType,
url: data,
};
}
return {
mimeType,
url: `data:${mimeType};base64,${data}`,
};
}
function buildOpenCodePromptParts(
prompt: AgentPromptInput,
): Array<OpenCodeTextPartInput | OpenCodeFilePartInput> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
let attachmentOrdinal = 0;
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
for (const part of prompt) {
if (part.type === "text") {
output.push({ type: "text", text: part.text });
continue;
}
attachmentOrdinal += 1;
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
output.push({
type: "file",
mime: normalized.mimeType,
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
normalized.mimeType,
)}`,
url: normalized.url,
});
}
return output;
}
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey,
@@ -1436,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession {
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = this.buildPromptParts(prompt);
const parts = buildOpenCodePromptParts(prompt);
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined;
@@ -1862,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession {
this.activeForegroundTurnId = null;
}
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
return prompt
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => ({ type: "text", text: p.text }));
}
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {

View File

@@ -298,6 +298,8 @@ export class PiACPAgentClient extends ACPAgentClient {
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
},
capabilities: PI_CAPABILITIES,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
});
}

View File

@@ -254,7 +254,7 @@ export async function createPaseoDaemon(
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");

View File

@@ -14,6 +14,14 @@ import {
} from "./test-utils/index.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
import type {
AgentClient,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
@@ -94,6 +102,116 @@ function waitForSignal<T>(
});
}
class NonPersistentReloadSession implements AgentSession {
readonly provider = "claude" as const;
readonly id = null;
readonly capabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
constructor(private readonly onClose: () => void) {}
async run(): Promise<AgentRunResult> {
return {
sessionId: "non-persistent",
finalText: "",
timeline: [],
};
}
async startTurn(): Promise<{ turnId: string }> {
return { turnId: "non-persistent-turn" };
}
subscribe(_callback: (event: AgentStreamEvent) => void): () => void {
return () => undefined;
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
return;
}
async getRuntimeInfo() {
return {
provider: "claude" as const,
sessionId: null,
model: null,
modeId: null,
};
}
async getAvailableModes(): Promise<[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return null;
}
async setMode(_modeId: string): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle | null {
return null;
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {
this.onClose();
}
}
class NonPersistentReloadClient implements AgentClient {
readonly provider = "claude" as const;
readonly capabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
createSessionCalls = 0;
resumeSessionCalls = 0;
closeCalls = 0;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
this.createSessionCalls += 1;
return new NonPersistentReloadSession(() => {
this.closeCalls += 1;
});
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeSessionCalls += 1;
return new NonPersistentReloadSession(() => {
this.closeCalls += 1;
});
}
async listModels() {
return [];
}
}
describe("daemon client E2E", () => {
let ctx: DaemonTestContext;
@@ -279,6 +397,38 @@ describe("daemon client E2E", () => {
}
}, 120000);
test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => {
const cwd = tmpCwd();
const client = new NonPersistentReloadClient();
const localCtx = await createDaemonTestContext({
agentClients: {
claude: client,
},
});
try {
const created = await localCtx.client.createAgent({
config: {
provider: "claude",
cwd,
},
});
expect(client.createSessionCalls).toBe(1);
expect(client.resumeSessionCalls).toBe(0);
expect(client.closeCalls).toBe(0);
await localCtx.client.refreshAgent(created.id);
expect(client.createSessionCalls).toBe(2);
expect(client.resumeSessionCalls).toBe(0);
expect(client.closeCalls).toBe(1);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent auto-unarchives archived agents", async () => {
const cwd = tmpCwd();
try {

View File

@@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_FILE_LEVEL: LogLevel = "trace";
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";

View File

@@ -231,8 +231,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
const MAX_TERMINAL_STREAM_SLOTS = 256;
function deriveInitialAgentTitle(prompt: string): string | null {
@@ -296,7 +294,6 @@ type ActiveTerminalStream = {
slot: number;
unsubscribe: () => void;
needsSnapshot: boolean;
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionRuntimeMetrics = {
@@ -408,7 +405,6 @@ export type SessionOptions = {
appVersion: string | null;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
@@ -563,7 +559,6 @@ export class Session {
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getBinaryBufferedAmount: (() => number) | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
@@ -662,7 +657,6 @@ export class Session {
appVersion,
onMessage,
onBinaryMessage,
getBinaryBufferedAmount,
onLifecycleIntent,
logger,
downloadTokenStore,
@@ -692,7 +686,6 @@ export class Session {
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
@@ -1746,6 +1739,22 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
case "stash_save_request":
await this.handleStashSaveRequest(msg);
break;
case "stash_pop_request":
await this.handleStashPopRequest(msg);
break;
case "stash_list_request":
await this.handleStashListRequest(msg);
break;
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
@@ -3095,11 +3104,7 @@ export class Session {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
if (existing.persistence) {
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
snapshot = existing;
}
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
@@ -4431,6 +4436,139 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
const { cwd, branch, requestId } = msg;
try {
await this.checkoutExistingBranch(cwd, branch);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd);
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: true,
branch,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: false,
branch,
error: toCheckoutError(error),
requestId,
},
});
}
}
// ---------------------------------------------------------------------------
// Stash handlers
// ---------------------------------------------------------------------------
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
private async handleStashSaveRequest(
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const branchLabel = msg.branch?.trim() ?? "";
const message = branchLabel
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
: `${Session.PASEO_STASH_PREFIX} unnamed`;
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_save_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashPopRequest(
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
): Promise<void> {
const { cwd, stashIndex, requestId } = msg;
try {
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_pop_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashListRequest(
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const paseoOnly = msg.paseoOnly !== false;
try {
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const lines = stdout.trim().split("\n").filter(Boolean);
const entries: Array<{
index: number;
message: string;
branch: string | null;
isPaseo: boolean;
}> = [];
for (const line of lines) {
const sepIdx = line.indexOf("\0");
if (sepIdx < 0) continue;
const refPart = line.slice(0, sepIdx);
const subject = line.slice(sepIdx + 1);
const indexMatch = refPart.match(/\{(\d+)\}/);
if (!indexMatch) continue;
const index = Number(indexMatch[1]);
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
const isPaseo = prefixIdx >= 0;
const branch = isPaseo
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
: null;
if (paseoOnly && !isPaseo) continue;
entries.push({ index, message: subject, branch, isPaseo });
}
this.emit({
type: "stash_list_response",
payload: { cwd, entries, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_list_response",
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
});
}
}
private async handleCheckoutCommitRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
): Promise<void> {
@@ -5326,7 +5464,7 @@ export class Session {
if (agent.status === "error" || agent.attentionReason === "error") {
return "failed";
}
if (agent.status === "running" || agent.status === "initializing") {
if (agent.status === "running") {
return "running";
}
if (agent.requiresAttention) {
@@ -8279,7 +8417,6 @@ export class Session {
slot,
unsubscribe: () => {},
needsSnapshot: true,
snapshotRetryTimer: null,
};
this.activeTerminalStreams.set(slot, activeStream);
@@ -8296,10 +8433,6 @@ export class Session {
if (activeStream.needsSnapshot || message.data.length === 0) {
return;
}
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
this.markAllActiveTerminalStreamsForSnapshot();
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
@@ -8307,9 +8440,6 @@ export class Session {
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
}),
);
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
this.markAllActiveTerminalStreamsForSnapshot();
}
});
return slot;
}
@@ -8322,21 +8452,6 @@ export class Session {
return;
}
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
if (!activeStream.snapshotRetryTimer) {
activeStream.snapshotRetryTimer = setTimeout(() => {
activeStream.snapshotRetryTimer = null;
this.trySendTerminalSnapshot(activeStream);
}, 33);
}
return;
}
if (activeStream.snapshotRetryTimer) {
clearTimeout(activeStream.snapshotRetryTimer);
activeStream.snapshotRetryTimer = null;
}
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
if (!terminal) {
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
@@ -8353,13 +8468,6 @@ export class Session {
);
}
private markAllActiveTerminalStreamsForSnapshot(): void {
for (const activeStream of this.activeTerminalStreams.values()) {
activeStream.needsSnapshot = true;
this.trySendTerminalSnapshot(activeStream);
}
}
private allocateTerminalSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
@@ -8384,10 +8492,6 @@ export class Session {
}
this.activeTerminalStreams.delete(slot);
this.terminalIdToSlot.delete(terminalId);
if (activeStream.snapshotRetryTimer) {
clearTimeout(activeStream.snapshotRetryTimer);
activeStream.snapshotRetryTimer = null;
}
try {
activeStream.unsubscribe();
} catch (error) {
@@ -8410,11 +8514,4 @@ export class Session {
}
}
private getCurrentBinaryBufferedAmount(): number {
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
return 0;
}
return Math.floor(bufferedAmount);
}
}

View File

@@ -396,7 +396,7 @@ export class VoiceAssistantWebSocketServer {
!!requestHost &&
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
if (!origin || allowedOrigins.has(origin) || sameOrigin) {
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
callback(true);
} else {
this.incrementRuntimeCounter("originRejected");
@@ -634,16 +634,6 @@ export class VoiceAssistantWebSocketServer {
}
this.sendBinaryToConnection(connection, frame);
},
getBinaryBufferedAmount: () => {
if (!connection) {
return 0;
}
let bufferedAmount = 0;
for (const socket of connection.sockets) {
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
}
return bufferedAmount;
},
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},

View File

@@ -1057,6 +1057,37 @@ export const ValidateBranchRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutSwitchBranchRequestSchema = z.object({
type: z.literal("checkout_switch_branch_request"),
cwd: z.string(),
branch: z.string(),
requestId: z.string(),
});
export const StashSaveRequestSchema = z.object({
type: z.literal("stash_save_request"),
cwd: z.string(),
/** Branch name to tag the stash with for later identification. */
branch: z.string().optional(),
requestId: z.string(),
});
export const StashPopRequestSchema = z.object({
type: z.literal("stash_pop_request"),
cwd: z.string(),
/** Zero-based index from stash_list_response. */
stashIndex: z.number().int().min(0),
requestId: z.string(),
});
export const StashListRequestSchema = z.object({
type: z.literal("stash_list_request"),
cwd: z.string(),
/** If true, only return paseo-created stashes. Default true. */
paseoOnly: z.boolean().optional(),
requestId: z.string(),
});
export const BranchSuggestionsRequestSchema = z.object({
type: z.literal("branch_suggestions_request"),
cwd: z.string(),
@@ -1392,6 +1423,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
CheckoutSwitchBranchRequestSchema,
StashSaveRequestSchema,
StashPopRequestSchema,
StashListRequestSchema,
ValidateBranchRequestSchema,
BranchSuggestionsRequestSchema,
DirectorySuggestionsRequestSchema,
@@ -2192,6 +2227,54 @@ export const CheckoutPrStatusResponseSchema = z.object({
}),
});
export const CheckoutSwitchBranchResponseSchema = z.object({
type: z.literal("checkout_switch_branch_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
branch: z.string(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
const StashEntrySchema = z.object({
index: z.number().int().min(0),
message: z.string(),
branch: z.string().nullable(),
isPaseo: z.boolean(),
});
export const StashSaveResponseSchema = z.object({
type: z.literal("stash_save_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const StashPopResponseSchema = z.object({
type: z.literal("stash_pop_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const StashListResponseSchema = z.object({
type: z.literal("stash_list_response"),
payload: z.object({
cwd: z.string(),
entries: z.array(StashEntrySchema),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const ValidateBranchResponseSchema = z.object({
type: z.literal("validate_branch_response"),
payload: z.object({
@@ -2578,6 +2661,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
CheckoutSwitchBranchResponseSchema,
StashSaveResponseSchema,
StashPopResponseSchema,
StashListResponseSchema,
ValidateBranchResponseSchema,
BranchSuggestionsResponseSchema,
DirectorySuggestionsResponseSchema,
@@ -2793,6 +2880,15 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
export type StashPopResponse = z.infer<typeof StashPopResponseSchema>;
export type StashListRequest = z.infer<typeof StashListRequestSchema>;
export type StashListResponse = z.infer<typeof StashListResponseSchema>;
export type StashEntry = z.infer<typeof StashEntrySchema>;
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;

View File

@@ -157,7 +157,6 @@ type CheckoutFileChange = {
isUntracked?: boolean;
};
type BranchSuggestionRefOrigin = "local" | "remote";
function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim();
@@ -176,47 +175,57 @@ function normalizeBranchSuggestionName(raw: string): string | null {
normalized = normalized.slice("origin/".length);
}
if (!normalized || normalized === "HEAD") {
if (!normalized || normalized === "HEAD" || normalized === "origin") {
return null;
}
return normalized;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
interface GitRef {
name: string;
committerDate: number;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
const { stdout } = await execGit(
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
{ cwd, env: READ_ONLY_GIT_ENV },
);
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return null;
const [name, dateStr] = trimmed.split("\t");
if (!name) return null;
return { name, committerDate: Number(dateStr) || 0 };
})
.filter((ref): ref is GitRef => ref !== null);
}
function sortBranchSuggestions(
branchNames: string[],
localBranchNames: Set<string>,
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
query: string,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
const hasQuery = normalizedQuery.length > 0;
return branchNames.sort((a, b) => {
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
if (hasQuery) {
const aPrefix = aLower.startsWith(normalizedQuery);
const bPrefix = bLower.startsWith(normalizedQuery);
const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1;
}
}
const aIsLocal = localBranchNames.has(a);
const bIsLocal = localBranchNames.has(b);
if (aIsLocal !== bIsLocal) {
return aIsLocal ? -1 : 1;
const aMeta = branchMeta.get(a);
const bMeta = branchMeta.get(b);
const aDate = aMeta?.committerDate ?? 0;
const bDate = bMeta?.committerDate ?? 0;
if (aDate !== bDate) {
return bDate - aDate;
}
return a.localeCompare(b);
@@ -238,41 +247,40 @@ export async function listBranchSuggestions(
listGitRefs(cwd, "refs/remotes/origin"),
]);
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
for (const localRef of localRefs) {
const normalized = normalizeBranchSuggestionName(localRef);
if (!normalized) {
continue;
}
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
origins.add("local");
merged.set(normalized, origins);
}
for (const remoteRef of remoteRefs) {
const normalized = normalizeBranchSuggestionName(remoteRef);
if (!normalized) {
continue;
}
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
origins.add("remote");
merged.set(normalized, origins);
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
for (const ref of localRefs) {
const normalized = normalizeBranchSuggestionName(ref.name);
if (!normalized) continue;
const existing = branchMeta.get(normalized);
branchMeta.set(normalized, {
isLocal: true,
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
});
}
const filteredNames = Array.from(merged.keys()).filter((name) =>
for (const ref of remoteRefs) {
const normalized = normalizeBranchSuggestionName(ref.name);
if (!normalized) continue;
const existing = branchMeta.get(normalized);
if (!existing) {
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
} else {
branchMeta.set(normalized, {
...existing,
committerDate: Math.max(ref.committerDate, existing.committerDate),
});
}
}
const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
query ? name.toLowerCase().includes(query) : true,
);
if (filteredNames.length === 0) {
return [];
}
const localBranchNames = new Set<string>();
for (const [name, origins] of merged) {
if (origins.has("local")) {
localBranchNames.add(name);
}
}
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit);
}

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