Compare commits

..
2 Commits
Author SHA1 Message Date
Mohamed Boudra d3876ffe61 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
2026-04-09 09:23:31 +07:00
Illia Panasenko 8f136a1f92 Add WebStorm editor target 2026-04-09 09:17:43 +07:00
103 changed files with 1159 additions and 3700 deletions
-171
View File
@@ -1,171 +0,0 @@
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'
-33
View File
@@ -1,38 +1,5 @@
# Changelog # 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 ## 0.1.50 - 2026-04-07
### Added ### Added
-168
View File
@@ -1,168 +0,0 @@
# 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
View File
@@ -1,428 +0,0 @@
# 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 |
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M="; npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
# Prevent onnxruntime-node's install script from running during automatic # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.52", "version": "0.1.51-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.52", "version": "0.1.51-rc.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -34906,16 +34906,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.52", "@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.52", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -35032,11 +35032,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.52", "@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -35077,11 +35077,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.52", "version": "0.1.51-rc.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.52", "@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -35115,7 +35115,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.52", "version": "0.1.51-rc.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -35316,7 +35316,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -35342,7 +35342,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -35358,14 +35358,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.52", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.52", "@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -35764,7 +35764,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.52", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.52", "version": "0.1.51-rc.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.52", "version": "0.1.51-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.52", "@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.52", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
+3 -4
View File
@@ -14,7 +14,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -58,7 +57,7 @@ import {
HorizontalScrollProvider, HorizontalScrollProvider,
useHorizontalScrollOptional, useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context"; } from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center"; import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
@@ -373,7 +372,7 @@ function AppContainer({
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => { useEffect(() => {
@@ -562,7 +561,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
UnistylesRuntime.setAdaptiveThemes(true); UnistylesRuntime.setAdaptiveThemes(true);
} else { } else {
UnistylesRuntime.setAdaptiveThemes(false); UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]); UnistylesRuntime.setTheme(settings.theme);
} }
}, [settingsLoading, settings.theme]); }, [settingsLoading, settings.theme]);
+51 -5
View File
@@ -6,6 +6,8 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera"; import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; 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 { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
@@ -59,7 +61,7 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute", position: "absolute",
width: 36, width: 36,
height: 36, height: 36,
borderColor: theme.colors.accent, borderColor: theme.colors.palette.blue[400],
}, },
cornerTL: { cornerTL: {
left: 0, left: 0,
@@ -146,11 +148,28 @@ export default function PairScanScreen() {
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null; const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts(); const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false); const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null); 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( const returnToSource = useCallback(
(serverId: string) => { (serverId: string) => {
@@ -205,6 +224,7 @@ export default function PairScanScreen() {
const handleScan = useCallback( const handleScan = useCallback(
async (result: BarcodeScanningResult) => { async (result: BarcodeScanningResult) => {
if (pendingNameHost) return;
if (isPairing) return; if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result); const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return; if (!offerUrl) return;
@@ -228,7 +248,7 @@ export default function PairScanScreen() {
return; return;
} }
const { client, hostname } = await connectToDaemon( const { client } = await connectToDaemon(
{ {
id: "probe", id: "probe",
type: "relay", type: "relay",
@@ -239,7 +259,13 @@ export default function PairScanScreen() {
); );
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
if (isNewHost) {
setPendingNameHost({ serverId: profile.serverId, hostname: null });
return;
}
returnToSource(profile.serverId); returnToSource(profile.serverId);
} catch (error) { } catch (error) {
@@ -250,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false); setIsPairing(false);
} }
}, },
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
); );
if (Platform.OS === "web") { if (Platform.OS === "web") {
@@ -281,6 +307,25 @@ export default function PairScanScreen() {
return ( return (
<View style={styles.container}> <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] }]}> <View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text> <Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}> <Pressable onPress={closeToSource}>
@@ -314,6 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} /> <View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} /> <View style={[styles.corner, styles.cornerBR]} />
</View> </View>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? ( {isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}> <Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing… Pairing…
@@ -3,8 +3,7 @@ import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native"; import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import { import {
BottomSheetModal, BottomSheetModal,
@@ -120,7 +119,7 @@ export function AdaptiveModalSheet({
testID, testID,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null); const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false); const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -240,7 +239,7 @@ export function AdaptiveModalSheet({
*/ */
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>( export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) { function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) { if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />; return <BottomSheetTextInput ref={ref as any} {...props} />;
@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native"; import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native"; import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -152,7 +151,7 @@ export function AddHostModal({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations(); const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null); const hostInputRef = useRef<TextInput>(null);
@@ -219,7 +218,6 @@ export function AddHostModal({
const profile = await upsertDirectConnection({ const profile = await upsertDirectConnection({
serverId, serverId,
endpoint, endpoint,
label: hostname ?? undefined,
}); });
onSaved?.({ profile, serverId, hostname, isNewHost }); onSaved?.({ profile, serverId, hostname, isNewHost });
@@ -1,7 +1,6 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
@@ -49,7 +48,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit"; import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -133,8 +131,6 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" || agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready"); agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -155,8 +151,10 @@ export function AgentInputArea({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor(); const isDesktopWebBreakpoint =
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; Platform.OS === "web" &&
UnistylesRuntime.breakpoint !== "xs" &&
UnistylesRuntime.breakpoint !== "sm";
const messagePlaceholder = isDesktopWebBreakpoint const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER ? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER; : MOBILE_MESSAGE_PLACEHOLDER;
@@ -744,7 +742,6 @@ export function AgentInputArea({
voiceServerId={serverId} voiceServerId={serverId}
voiceAgentId={agentId} voiceAgentId={agentId}
isAgentRunning={isAgentRunning} isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue} onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress} onKeyPress={handleCommandKeyPress}
+2 -3
View File
@@ -10,8 +10,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -215,7 +214,7 @@ export function AgentList({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -88,7 +88,6 @@ type ControlledAgentStatusBarProps = {
features?: AgentFeature[]; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
}; };
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
@@ -111,7 +110,6 @@ export interface DraftAgentStatusBarProps {
features?: AgentFeature[]; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -219,7 +217,6 @@ function ControlledStatusBar({
features, features,
onSetFeature, onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
@@ -414,7 +411,6 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel} onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
/> />
</View> </View>
@@ -666,7 +662,6 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel} onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => ( renderTrigger={({ selectedModelLabel }) => (
<View <View
@@ -880,7 +875,6 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
entries: snapshotEntries, entries: snapshotEntries,
isLoading: snapshotIsLoading, isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching, isFetching: snapshotIsFetching,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId); } = useProvidersSnapshot(serverId);
const snapshotModels = useMemo(() => { const snapshotModels = useMemo(() => {
@@ -968,14 +962,13 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
return; return;
} }
void updatePreferences( void updatePreferences(
(current) => mergeProviderPreferences({
mergeProviderPreferences({ preferences,
preferences: current, provider: agent.provider,
provider: agent.provider, updates: {
updates: { model: modelId,
model: modelId, },
}, }),
}),
).catch((error) => { ).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error); console.warn("[AgentStatusBar] persist model preference failed", error);
}); });
@@ -985,7 +978,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
}} }}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => { onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error); console.warn("[AgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
@@ -998,17 +991,16 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
const activeModelId = modelSelection.activeModelId; const activeModelId = modelSelection.activeModelId;
if (activeModelId) { if (activeModelId) {
void updatePreferences( void updatePreferences(
(current) => mergeProviderPreferences({
mergeProviderPreferences({ preferences,
preferences: current, provider: agent.provider,
provider: agent.provider, updates: {
updates: { model: activeModelId,
model: activeModelId, thinkingByModel: {
thinkingByModel: { [activeModelId]: thinkingOptionId,
[activeModelId]: thinkingOptionId,
},
}, },
}), },
}),
).catch((error) => { ).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error); console.warn("[AgentStatusBar] persist thinking preference failed", error);
}); });
@@ -1022,26 +1014,11 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
if (!client) { if (!client) {
return; 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) => { void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error); console.warn("[AgentStatusBar] setAgentFeature failed", error);
}); });
}} }}
isModelLoading={snapshotIsLoading || snapshotIsFetching} isModelLoading={snapshotIsLoading || snapshotIsFetching}
onModelSelectorOpen={invalidateSnapshot}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
disabled={!client} disabled={!client}
/> />
@@ -1068,7 +1045,6 @@ export function DraftAgentStatusBar({
features, features,
onSetFeature, onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
@@ -1107,13 +1083,12 @@ export function DraftAgentStatusBar({
onSelect={onSelectProviderAndModel} onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => { onToggleFavorite={(provider, modelId) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
isLoading={isAllModelsLoading} isLoading={isAllModelsLoading}
disabled={disabled} disabled={disabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
/> />
<ControlledStatusBar <ControlledStatusBar
@@ -1154,7 +1129,7 @@ export function DraftAgentStatusBar({
isModelLoading={isAllModelsLoading} isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => { onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
}); });
}} }}
@@ -1163,7 +1138,6 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
features={features} features={features}
onSetFeature={onSetFeature} onSetFeature={onSetFeature}
onModelSelectorOpen={onModelSelectorOpen}
disabled={disabled} disabled={disabled}
/> />
</> </>
@@ -9,8 +9,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import Animated, { import Animated, {
@@ -110,7 +109,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const viewportRef = useRef<StreamViewportHandle | null>(null); const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const router = useRouter(); const router = useRouter();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const streamRenderStrategy = useMemo( const streamRenderStrategy = useMemo(
() => () =>
resolveStreamRenderStrategy({ resolveStreamRenderStrategy({
@@ -708,7 +707,7 @@ function PermissionRequestCard({
client: DaemonClient | null; client: DaemonClient | null;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { request } = permission; const { request } = permission;
const isPlanRequest = request.kind === "plan"; const isPlanRequest = request.kind === "plan";
@@ -1,125 +0,0 @@
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,
},
}));
@@ -10,7 +10,6 @@ import {
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
ArrowLeft, ArrowLeft,
ChevronDown, ChevronDown,
@@ -59,7 +58,6 @@ interface CombinedModelSelectorProps {
disabled: boolean; disabled: boolean;
isOpen: boolean; isOpen: boolean;
}) => React.ReactNode; }) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -350,8 +348,7 @@ function ProviderSearchInput({
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor(); const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) { if (autoFocus && Platform.OS === "web" && inputRef.current) {
@@ -518,7 +515,6 @@ export function CombinedModelSelector({
favoriteKeys = new Set<string>(), favoriteKeys = new Set<string>(),
onToggleFavorite, onToggleFavorite,
renderTrigger, renderTrigger,
onOpen,
onClose, onClose,
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
@@ -543,14 +539,12 @@ export function CombinedModelSelector({
(open: boolean) => { (open: boolean) => {
setIsOpen(open); setIsOpen(open);
setView(singleProviderView ?? { kind: "all" }); setView(singleProviderView ?? { kind: "all" });
if (open) { if (!open) {
onOpen?.();
} else {
setSearchQuery(""); setSearchQuery("");
onClose?.(); onClose?.();
} }
}, },
[onOpen, onClose, singleProviderView], [onClose, singleProviderView],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
@@ -20,7 +20,7 @@ import {
type ExplorerTab, type ExplorerTab,
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane"; import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -48,7 +48,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}, },
tabActive: { tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
tabText: { tabText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -10,8 +10,7 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler"; import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, { import Animated, {
cancelAnimation, cancelAnimation,
Easing, Easing,
@@ -90,7 +89,7 @@ export function FileExplorerPane({
onOpenFile, onOpenFile,
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts(); const daemons = useHosts();
@@ -849,7 +848,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}, },
entryRowActive: { entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
indentGuide: { indentGuide: {
position: "absolute", position: "absolute",
+2 -3
View File
@@ -8,8 +8,7 @@ import {
View, View,
Platform, Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
@@ -242,7 +241,7 @@ export function FilePane({
workspaceRoot: string; workspaceRoot: string;
filePath: string; filePath: string;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
+44 -44
View File
@@ -21,8 +21,7 @@ import {
TextStyle, TextStyle,
} from "react-native"; } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
AlignJustify, AlignJustify,
Archive, Archive,
@@ -57,12 +56,7 @@ import {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
buildSplitDiffRows,
buildUnifiedDiffLines,
type SplitDiffDisplayLine,
type SplitDiffRow,
} from "@/utils/diff-layout";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -80,11 +74,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; 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"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -175,7 +164,7 @@ function DiffGutterCell({
type === "remove" && styles.removeLineNumberText, type === "remove" && styles.removeLineNumberText,
]} ]}
> >
{formatDiffGutterText(lineNumber)} {lineNumber != null ? String(lineNumber) : ""}
</Text> </Text>
</View> </View>
); );
@@ -188,12 +177,10 @@ function DiffTextLine({
line: DiffLine; line: DiffLine;
wrapLines: boolean; wrapLines: boolean;
}) { }) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return ( return (
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}> <View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
{line.type !== "header" && visibleTokens ? ( {line.tokens && line.type !== "header" ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : ( ) : (
<Text <Text
style={[ style={[
@@ -205,7 +192,7 @@ function DiffTextLine({
line.type === "context" && styles.contextLineText, line.type === "context" && styles.contextLineText,
]} ]}
> >
{formatDiffContentText(line.content)} {line.content || " "}
</Text> </Text>
)} )}
</View> </View>
@@ -219,12 +206,10 @@ function SplitTextLine({
line: SplitDiffDisplayLine | null; line: SplitDiffDisplayLine | null;
wrapLines: boolean; wrapLines: boolean;
}) { }) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return ( return (
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}> <View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
{visibleTokens ? ( {line?.tokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : ( ) : (
<Text <Text
style={[ style={[
@@ -236,7 +221,7 @@ function SplitTextLine({
!line && styles.emptySplitCellText, !line && styles.emptySplitCellText,
]} ]}
> >
{formatDiffContentText(line?.content)} {line?.content ?? ""}
</Text> </Text>
)} )}
</View> </View>
@@ -254,8 +239,6 @@ function DiffLineView({
gutterWidth: number; gutterWidth: number;
wrapLines: boolean; wrapLines: boolean;
}) { }) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return ( return (
<View <View
style={[ style={[
@@ -271,11 +254,11 @@ function DiffLineView({
line.type === "remove" && styles.removeLineNumberText, line.type === "remove" && styles.removeLineNumberText,
]} ]}
> >
{formatDiffGutterText(lineNumber)} {lineNumber != null ? String(lineNumber) : ""}
</Text> </Text>
</View> </View>
{line.type !== "header" && visibleTokens ? ( {line.tokens && line.type !== "header" ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : ( ) : (
<Text <Text
style={[ style={[
@@ -287,7 +270,7 @@ function DiffLineView({
line.type === "context" && styles.contextLineText, line.type === "context" && styles.contextLineText,
]} ]}
> >
{formatDiffContentText(line.content)} {line.content || " "}
</Text> </Text>
)} )}
</View> </View>
@@ -303,8 +286,6 @@ function SplitDiffLine({
gutterWidth: number; gutterWidth: number;
wrapLines: boolean; wrapLines: boolean;
}) { }) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return ( return (
<View <View
style={[ style={[
@@ -320,11 +301,11 @@ function SplitDiffLine({
line?.type === "remove" && styles.removeLineNumberText, line?.type === "remove" && styles.removeLineNumberText,
]} ]}
> >
{formatDiffGutterText(line?.lineNumber ?? null)} {line?.lineNumber != null ? String(line.lineNumber) : ""}
</Text> </Text>
</View> </View>
{visibleTokens ? ( {line?.tokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : ( ) : (
<Text <Text
style={[ style={[
@@ -336,7 +317,7 @@ function SplitDiffLine({
!line && styles.emptySplitCellText, !line && styles.emptySplitCellText,
]} ]}
> >
{formatDiffContentText(line?.content)} {line?.content ?? ""}
</Text> </Text>
)} )}
</View> </View>
@@ -553,7 +534,26 @@ function DiffFileBody({
); );
} }
const computedLines = buildUnifiedDiffLines(file); 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}` });
}
}
if (wrapLines) { if (wrapLines) {
return ( return (
@@ -607,7 +607,7 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter(); const router = useRouter();
@@ -1724,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({
newBadgeText: { newBadgeText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition, color: theme.colors.palette.green[400],
}, },
deletedBadge: { deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)", backgroundColor: "rgba(248, 81, 73, 0.2)",
@@ -1736,17 +1736,17 @@ const styles = StyleSheet.create((theme) => ({
deletedBadgeText: { deletedBadgeText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion, color: theme.colors.palette.red[500],
}, },
additions: { additions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition, color: theme.colors.palette.green[400],
}, },
deletions: { deletions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion, color: theme.colors.palette.red[500],
}, },
diffContent: { diffContent: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: theme.borderWidth[1],
@@ -1824,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none", userSelect: "none",
}, },
addLineNumberText: { addLineNumberText: {
color: theme.colors.diffAddition, color: theme.colors.palette.green[400],
}, },
removeLineNumberText: { removeLineNumberText: {
color: theme.colors.diffDeletion, color: theme.colors.palette.red[500],
}, },
diffLineText: { diffLineText: {
flex: 1, flex: 1,
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button"; import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps { interface MenuHeaderProps {
@@ -44,7 +44,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button", nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) { }: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
@@ -6,7 +6,7 @@ import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE, HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE, HEADER_TOP_PADDING_MOBILE,
useIsCompactFormFactor, isCompactFormFactor,
} from "@/constants/layout"; } from "@/constants/layout";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -26,7 +26,7 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) { export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const padding = useWindowControlsPadding("header"); const padding = useWindowControlsPadding("header");
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
+5 -5
View File
@@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
import { import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE, HEADER_INNER_HEIGHT_MOBILE,
useIsCompactFormFactor, isCompactFormFactor,
} from "@/constants/layout"; } from "@/constants/layout";
import { import {
buildHostSessionsRoute, buildHostSessionsRoute,
@@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
newAgentButtonHovered: { newAgentButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
newAgentButtonActive: { newAgentButtonActive: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
hostTrigger: { hostTrigger: {
flexDirection: "row", flexDirection: "row",
@@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
hostTriggerHovered: { hostTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
hostStatusDot: { hostStatusDot: {
width: 8, width: 8,
+17 -50
View File
@@ -87,9 +87,6 @@ export interface MessageInputProps {
voiceAgentId?: string; voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */ /** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean; 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 */ /** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void; onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */ /** Optional handler used when submit button is in loading state. */
@@ -211,7 +208,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
voiceServerId, voiceServerId,
voiceAgentId, voiceAgentId,
isAgentRunning = false, isAgentRunning = false,
defaultSendBehavior = "interrupt",
onQueue, onQueue,
onSubmitLoadingPress, onSubmitLoadingPress,
onKeyPress: onKeyPressCallback, onKeyPress: onKeyPressCallback,
@@ -356,17 +352,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) { if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined; const imageAttachments = images.length > 0 ? images : undefined;
// Respect send behavior setting: when "queue", dictation queues too. onSubmit({
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { text: nextValue,
onQueue({ text: nextValue, images: imageAttachments }); images: imageAttachments,
onChangeText(""); forceSend: isAgentRunning || undefined,
} else { });
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
}
} else { } else {
onChangeText(nextValue); onChangeText(nextValue);
} }
@@ -377,7 +367,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}); });
} }
}, },
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior], [onChangeText, onSubmit, images, isAgentRunning],
); );
const handleDictationError = useCallback( const handleDictationError = useCallback(
@@ -588,26 +578,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeightChange?.(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]); }, [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 // Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle { function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v; return typeof v === "object" && v !== null && "scrollHeight" in v;
@@ -902,18 +872,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Shift+Enter: add newline (default behavior, don't intercept) // Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return; if (shiftKey) return;
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
handleAlternateSendAction(); handleQueueMessage();
return; return;
} }
// Enter: default send action (interrupt or queue, based on setting) // Enter: send (interrupts agent if running)
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
handleDefaultSendAction(); handleSendMessage();
} }
const hasImages = images.length > 0; const hasImages = images.length > 0;
@@ -922,14 +892,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled = const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent" ? "Interrupt agent"
: defaultActionQueues : isAgentRunning
? "Queue message" ? "Send and interrupt"
: isAgentRunning : "Send message";
? "Send and interrupt"
: "Send message";
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
@@ -1104,10 +1071,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{rightContent} {rightContent}
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && ( {hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleAlternateSendAction} onPress={handleQueueMessage}
disabled={!isConnected || disabled} disabled={!isConnected || disabled}
accessibilityLabel="Queue message" accessibilityLabel="Queue message"
accessibilityRole="button" accessibilityRole="button"
@@ -1132,7 +1099,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{shouldShowSendButton && ( {shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction} onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
disabled={isSendButtonDisabled} disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel} accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button" accessibilityRole="button"
@@ -1146,7 +1113,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text> <Text style={styles.tooltipText}>Send</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null} {sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
+2 -3
View File
@@ -41,8 +41,7 @@ import {
Scissors, Scissors,
MicVocal, MicVocal,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, { import Animated, {
Easing, Easing,
cancelAnimation, cancelAnimation,
@@ -1823,7 +1822,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand) // Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => { const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) { if (detail) {
@@ -0,0 +1,117 @@
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>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native"; import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -67,7 +66,7 @@ export function PairLinkModal({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState(""); const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -147,7 +146,7 @@ export function PairLinkModal({
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined); const profile = await upsertDaemonFromOfferUrl(raw);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose(); handleClose();
} catch (error) { } catch (error) {
@@ -1,7 +1,6 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -64,7 +63,7 @@ const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input); const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({}); const [selections, setSelections] = useState<Record<number, Set<number>>>({});
@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types"; import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -713,7 +713,7 @@ function ProjectHeaderRow({
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor(); const isMobileBreakpoint = isCompactFormFactor();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast(); const toast = useToast();
@@ -1606,7 +1606,7 @@ export function SidebarWorkspaceList({
listFooterComponent, listFooterComponent,
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const isNative = Platform.OS !== "web"; const isNative = Platform.OS !== "web";
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
@@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
projectRowHovered: { projectRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
projectRowPressed: { projectRowPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
projectActionButtonHovered: { projectActionButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
projectActionButtonText: { projectActionButtonText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
projectIconActionButtonHovered: { projectIconActionButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
projectIconActionButtonHidden: { projectIconActionButtonHidden: {
opacity: 0, opacity: 0,
@@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
workspaceRowHovered: { workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
workspaceRowPressed: { workspaceRowPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.md, ...theme.shadow.md,
}, },
sidebarRowSelected: { sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surface1,
}, },
workspaceRowContainer: { workspaceRowContainer: {
position: "relative", position: "relative",
@@ -2241,12 +2241,12 @@ const styles = StyleSheet.create((theme) => ({
diffStatAdditions: { diffStatAdditions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition, color: theme.colors.palette.green[400],
}, },
diffStatDeletions: { diffStatDeletions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion, color: theme.colors.palette.red[500],
}, },
kebabButton: { kebabButton: {
padding: 2, padding: 2,
@@ -21,7 +21,7 @@ import {
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme"; import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isCompactFormFactor } from "@/constants/layout";
interface TerminalPaneProps { interface TerminalPaneProps {
serverId: string; serverId: string;
@@ -92,7 +92,7 @@ export function TerminalPane({
const isAppVisible = useAppVisible(); const isAppVisible = useAppVisible();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
+2 -3
View File
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { import {
@@ -109,7 +108,6 @@ export function ToastViewport({
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const opacity = useRef(new Animated.Value(0)).current; const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current; const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -183,6 +181,7 @@ export function ToastViewport({
return null; return null;
} }
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset = const topOffset =
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
buildVisibleComboboxOptions, buildVisibleComboboxOptions,
filterAndRankComboboxOptions,
getComboboxFallbackIndex, getComboboxFallbackIndex,
orderVisibleComboboxOptions, orderVisibleComboboxOptions,
} from "./combobox-options"; } from "./combobox-options";
@@ -48,48 +47,6 @@ 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", () => { describe("combobox above-search ordering", () => {
const visible = [ const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const }, { id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
@@ -35,33 +35,18 @@ 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( export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput, input: BuildVisibleComboboxOptionsInput,
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch); 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 sanitizedSearchValue = input.searchQuery.trim(); const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({ const showCustomOption = shouldShowCustomComboboxOption({
+21 -13
View File
@@ -11,8 +11,7 @@ import {
StatusBar, StatusBar,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetModal, BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
@@ -262,7 +261,7 @@ export function Combobox({
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
@@ -660,7 +659,13 @@ export function Combobox({
</> </>
); );
const defaultContent = optionsList; const defaultContent = (
<>
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
);
const content = children ?? defaultContent; const content = children ?? defaultContent;
@@ -685,7 +690,6 @@ export function Combobox({
<Text style={styles.comboboxTitle}>{title}</Text> <Text style={styles.comboboxTitle}>{title}</Text>
</View> </View>
{stickyHeader} {stickyHeader}
{!children && searchable ? searchInput : null}
<BottomSheetScrollView <BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent} contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -741,7 +745,6 @@ export function Combobox({
</> </>
) : ( ) : (
<> <>
{searchable ? searchInput : null}
{effectiveOptionsPosition === "above-search" ? ( {effectiveOptionsPosition === "above-search" ? (
<ScrollView <ScrollView
ref={desktopOptionsScrollRef} ref={desktopOptionsScrollRef}
@@ -756,7 +759,9 @@ export function Combobox({
> >
{optionsList} {optionsList}
</ScrollView> </ScrollView>
) : ( ) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? (
<ScrollView <ScrollView
contentContainerStyle={styles.desktopScrollContent} contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -765,7 +770,7 @@ export function Combobox({
> >
{optionsList} {optionsList}
</ScrollView> </ScrollView>
)} ) : null}
</> </>
)} )}
</Animated.View> </Animated.View>
@@ -778,12 +783,15 @@ const styles = StyleSheet.create((theme) => ({
searchInputContainer: { searchInputContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: theme.spacing[3], borderWidth: 1,
gap: theme.spacing[2], borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
borderBottomWidth: 1, borderRadius: theme.borderRadius.lg,
borderBottomColor: theme.colors.border, paddingHorizontal: theme.spacing[3],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
marginTop: theme.spacing[1],
gap: theme.spacing[2],
}, },
searchInput: { searchInput: {
flex: 1, flex: 1,
@@ -27,8 +27,7 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -348,7 +347,7 @@ export function ContextMenuContent({
testID?: string; testID?: string;
}>): ReactElement | null { }>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent"); const context = useContextMenuContext("ContextMenuContent");
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const useMobileSheet = isMobile && mobileMode === "sheet"; const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context; const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
+3 -3
View File
@@ -551,9 +551,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl, borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.popover, backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[2],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.border,
...theme.shadow.md, ...theme.shadow.sm,
zIndex: 1000, zIndex: 1000,
}, },
})); }));
+60 -3
View File
@@ -8,11 +8,14 @@ import type { HostProfile } from "@/types/host-connection";
import { import {
getHostRuntimeStore, getHostRuntimeStore,
isHostRuntimeConnected, isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot, useHostRuntimeSnapshot,
useHosts, useHosts,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal"; import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal"; import { PairLinkModal } from "./pair-link-modal";
import { NameHostModal } from "./name-host-modal";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from "@/utils/host-routes";
@@ -234,19 +237,41 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false); const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = 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 hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); 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(() => { useEffect(() => {
if (!anyOnlineServerId) { if (!anyOnlineServerId) {
return; return;
} }
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId)); router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, router]); }, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback( const finishOnboarding = useCallback(
(serverId: string) => { (serverId: string) => {
@@ -371,8 +396,13 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal <AddHostModal
visible={isDirectOpen} visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)} onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => { onSaved={({ profile, serverId, hostname, isNewHost }) => {
onHostAdded?.(profile); onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId); finishOnboarding(serverId);
}} }}
/> />
@@ -380,11 +410,38 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<PairLinkModal <PairLinkModal
visible={isPasteLinkOpen} visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)} onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => { onSaved={({ profile, serverId, hostname, isNewHost }) => {
onHostAdded?.(profile); onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId); 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> </ScrollView>
); );
} }
+11 -8
View File
@@ -1,5 +1,5 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles"; import { UnistylesRuntime } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75; export const FOOTER_HEIGHT = 75;
@@ -62,13 +62,16 @@ export function getIsElectronRuntime(): boolean {
return result; return result;
} }
/** export function isCompactFormFactor(): boolean {
* Reactive hook — re-renders the component when the breakpoint changes. return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
* Always use this instead of reading UnistylesRuntime.breakpoint directly. }
*/
export function useIsCompactFormFactor(): boolean { export function isDesktopFormFactor(): boolean {
const { rt } = useUnistyles(); return !isCompactFormFactor();
return rt.breakpoint === "xs" || rt.breakpoint === "sm"; }
export function isTouchDesktopFormFactor(): boolean {
return Platform.OS !== "web" && isDesktopFormFactor();
} }
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { import {
getRightSidebarAnimationTargets, getRightSidebarAnimationTargets,
@@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) { export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
@@ -10,7 +10,7 @@ import {
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { import {
getLeftSidebarAnimationTargets, getLeftSidebarAnimationTargets,
@@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) { export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
@@ -1,6 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native"; import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { import {
@@ -20,12 +21,15 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
restartDesktopDaemon, restartDesktopDaemon,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
startDesktopDaemon, startDesktopDaemon,
stopDesktopDaemon, stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null; appVersion: string | null;
@@ -36,18 +40,44 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null); const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const daemonStatus = data?.status ?? null; const loadDaemonData = useCallback(() => {
const daemonLogs = data?.logs ?? null; if (!showSection) {
const daemonVersion = daemonStatus?.version ?? null; 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 daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText = const daemonStatusStateText =
@@ -86,12 +116,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action() void action()
.then((newStatus) => { .then((status) => {
setStatus(newStatus); setDaemonStatus(status);
setStatusMessage( setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
); );
refetch(); return loadDaemonData();
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", error); console.error("[Settings] Failed to change desktop daemon state", error);
@@ -106,7 +136,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
console.error("[Settings] Failed to open desktop daemon action confirmation", error); console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog."); Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
}); });
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]); }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
const handleToggleDaemonManagement = useCallback(() => { const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) { if (isUpdatingDaemonManagement) {
@@ -152,14 +182,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: Promise.resolve(daemonStatus ?? null); : Promise.resolve(daemonStatus ?? null);
void stopPromise void stopPromise
.then((newStatus) => { .then(() => updateSettings({ manageBuiltInDaemon: false }))
if (newStatus) { .then(() => loadDaemonData())
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => { .then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped."); setStatusMessage("Built-in daemon paused and stopped.");
}) })
.catch((error) => { .catch((error) => {
@@ -177,8 +202,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
}, [ }, [
daemonStatus, daemonStatus,
isUpdatingDaemonManagement, isUpdatingDaemonManagement,
refetch, loadDaemonData,
setStatus,
settings.manageBuiltInDaemon, settings.manageBuiltInDaemon,
updateSettings, updateSettings,
]); ]);
@@ -253,138 +277,126 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings Advanced settings
</Button> </Button>
</View> </View>
{isLoading ? ( <View style={settingsStyles.card}>
<View style={[settingsStyles.card, styles.loadingCard]}> <View style={settingsStyles.row}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <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> </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.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text> <Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable."} {isDaemonManagementPaused
</Text> ? "Paused. The built-in daemon stays stopped until you start it again."
</View> : "Enabled. Paseo can manage the built-in daemon from the desktop app."}
<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> </Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />} leftIcon={
onPress={() => void handleOpenCliStatus()} isDaemonManagementPaused ? (
disabled={isLoadingCliStatus} <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
> >
{isLoadingCliStatus ? "Loading..." : "View status"} {isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button> </Button>
</View> </View>
</View> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
{daemonVersionMismatch ? ( <Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<View style={styles.warningCard}> <Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
<Text style={styles.warningText}> {statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
App and daemon versions don't match. Update both to the same version for the best </View>
experience. <Button
</Text> 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> </View>
) : null} </>
</> ) : 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}
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isLogsModalOpen} visible={isLogsModalOpen}
@@ -435,11 +447,6 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap", flexWrap: "wrap",
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: { statusValueGroup: {
alignItems: "flex-end", alignItems: "flex-end",
gap: 2, gap: 2,
@@ -1,53 +0,0 @@
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,
};
}
@@ -4,7 +4,6 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean; hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string | null; currentVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
body: string | null; body: string | null;
@@ -77,7 +76,6 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
return { return {
hasUpdate: result.hasUpdate === true, hasUpdate: result.hasUpdate === true,
readyToInstall: result.readyToInstall === true,
currentVersion: toStringOrNull(result.currentVersion), currentVersion: toStringOrNull(result.currentVersion),
latestVersion: toStringOrNull(result.latestVersion), latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body), body: toStringOrNull(result.body),
@@ -55,7 +55,7 @@ export function UpdateBanner() {
function getSubtitle(): string { function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version."; if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Installing and restarting..."; if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong."; 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.`; return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
} }
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { import {
checkDesktopAppUpdate, checkDesktopAppUpdate,
formatVersionWithPrefix, formatVersionWithPrefix,
@@ -11,15 +11,12 @@ import {
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
| "checking" | "checking"
| "pending"
| "up-to-date" | "up-to-date"
| "available" | "available"
| "installing" | "installing"
| "installed" | "installed"
| "error"; | "error";
const PENDING_RECHECK_MS = 10_000;
export interface UseDesktopAppUpdaterReturn { export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean; isDesktopApp: boolean;
status: DesktopAppUpdateStatus; status: DesktopAppUpdateStatus;
@@ -59,15 +56,11 @@ function formatStatusText(input: {
return "App is up to date."; return "App is up to date.";
} }
if (status === "pending") {
return "We'll let you know when the update is ready.";
}
if (status === "available") { if (status === "available") {
if (availableUpdate?.latestVersion) { if (availableUpdate?.latestVersion) {
return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`; return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
} }
return "An app update is ready to install."; return "An app update is available.";
} }
if (status === "installed") { if (status === "installed") {
@@ -113,12 +106,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setInstallMessage(null); setInstallMessage(null);
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.readyToInstall) { if (result.hasUpdate) {
setAvailableUpdate(result); setAvailableUpdate(result);
setStatus("available"); setStatus("available");
} else if (result.hasUpdate) {
setAvailableUpdate(null);
setStatus("pending");
} else { } else {
setAvailableUpdate(null); setAvailableUpdate(null);
setStatus("up-to-date"); setStatus("up-to-date");
@@ -143,20 +133,6 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
[isDesktopApp], [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 () => { const installUpdate = useCallback(async () => {
if (!isDesktopApp) { if (!isDesktopApp) {
return null; return null;
@@ -1,53 +0,0 @@
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,
});
});
});
@@ -1,60 +0,0 @@
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;
}
+18 -25
View File
@@ -93,7 +93,6 @@ type UseAgentFormStateResult = {
isModelLoading: boolean; isModelLoading: boolean;
modelError: string | null; modelError: string | null;
refreshProviderModels: () => void; refreshProviderModels: () => void;
invalidateProviderModels: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean; workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
@@ -376,7 +375,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isFetching: snapshotIsFetching, isFetching: snapshotIsFetching,
error: snapshotError, error: snapshotError,
refresh: refreshSnapshot, refresh: refreshSnapshot,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(formState.serverId); } = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
@@ -650,36 +648,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshSnapshot(); refreshSnapshot();
}, [refreshSnapshot]); }, [refreshSnapshot]);
const invalidateProviderModels = useCallback(() => {
invalidateSnapshot();
}, [invalidateSnapshot]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model; const modelId = resolvedModel?.id ?? formState.model;
await updatePreferences((current) => const nextPreferences = mergeProviderPreferences({
mergeProviderPreferences({ preferences: preferences ?? {},
preferences: current, provider: formState.provider,
provider: formState.provider, updates: {
updates: { model: modelId || undefined,
model: modelId || undefined, mode: formState.modeId || undefined,
mode: formState.modeId || undefined, ...(modelId && formState.thinkingOptionId
...(modelId && formState.thinkingOptionId ? {
? { thinkingByModel: {
thinkingByModel: { [modelId]: formState.thinkingOptionId,
[modelId]: formState.thinkingOptionId, },
}, }
} : {}),
: {}), } satisfies Partial<ProviderPreferences>,
} satisfies Partial<ProviderPreferences>, });
}),
); await updatePreferences(nextPreferences);
}, [ }, [
availableModels, availableModels,
formState.model, formState.model,
formState.modeId, formState.modeId,
formState.provider, formState.provider,
formState.thinkingOptionId, formState.thinkingOptionId,
preferences?.providerPreferences,
updatePreferences, updatePreferences,
]); ]);
@@ -720,7 +715,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser, setProviderAndModelFromUser,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
@@ -752,7 +746,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser, setProviderAndModelFromUser,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
@@ -1,164 +0,0 @@
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 };
}
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react"; import { useCallback, useEffect, useId, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
@@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab); const explorerTab = usePanelStore((state) => state.explorerTab);
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useIsCompactFormFactor } from "@/constants/layout"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutStatusResponse } from "@server/shared/messages"; import type { CheckoutStatusResponse } from "@server/shared/messages";
@@ -32,7 +32,7 @@ function fetchCheckoutStatus(
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab); const explorerTab = usePanelStore((state) => state.explorerTab);
@@ -1,16 +1,50 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { import type {
AgentFeature,
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import { function pruneFeatureValues(
applyFeatureValues, featureValues: Record<string, unknown>,
pruneFeatureValues, features: AgentFeature[],
resolveFeatureValues, ): Record<string, unknown> {
} from "./feature-preferences"; 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;
});
}
type DraftFeatureConfig = Pick< type DraftFeatureConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
@@ -26,15 +60,10 @@ export function useDraftAgentFeatures(input: {
thinkingOptionId: string | null | undefined; thinkingOptionId: string | null | undefined;
}) { }) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({}); const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || ""; const normalizedCwd = cwd?.trim() || "";
const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => { const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) { if (!normalizedCwd) {
@@ -73,56 +102,28 @@ export function useDraftAgentFeatures(input: {
return payload.features ?? []; return payload.features ?? [];
}, },
}); });
const availableFeatures = featuresQuery.data ?? [];
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => { const features = useMemo(() => {
return applyFeatureValues(availableFeatures, featureValues); return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [availableFeatures, featureValues]); }, [featureValues, featuresQuery.data]);
useEffect(() => { useEffect(() => {
setLocalFeatureValues({}); const next = pruneFeatureValues(featureValues, features);
}, [provider]); if (next !== featureValues) {
setFeatureValues(next);
useEffect(() => {
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) {
setLocalFeatureValues(next);
} }
}, [availableFeatures, localFeatureValues]); }, [featureValues, features]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => { const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setLocalFeatureValues((current) => { setFeatureValues((current) => {
if (Object.is(current[featureId], value)) { if (Object.is(current[featureId], value)) {
return current; return current;
} }
return { ...current, [featureId]: value }; 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 { return {
features, features,
@@ -59,41 +59,6 @@ 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", () => { describe("favorite model preferences", () => {
+3 -15
View File
@@ -25,7 +25,6 @@ const providerPreferencesSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(), thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
}); });
const formPreferencesSchema = z.object({ const formPreferencesSchema = z.object({
@@ -54,9 +53,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn { export interface UseFormPreferencesReturn {
preferences: FormPreferences; preferences: FormPreferences;
isLoading: boolean; isLoading: boolean;
updatePreferences: ( updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
} }
export function mergeProviderPreferences(args: { export function mergeProviderPreferences(args: {
@@ -74,13 +71,6 @@ export function mergeProviderPreferences(args: {
...existing.thinkingByModel, ...existing.thinkingByModel,
...updates.thinkingByModel, ...updates.thinkingByModel,
}; };
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return { return {
...preferences, ...preferences,
@@ -91,7 +81,6 @@ export function mergeProviderPreferences(args: {
...existing, ...existing,
...updates, ...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
}, },
}, },
}; };
@@ -144,12 +133,11 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES; const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback( const updatePreferences = useCallback(
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => { async (updates: Partial<FormPreferences>) => {
const prev = const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ?? queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES; DEFAULT_FORM_PREFERENCES;
const next = const next = { ...prev, ...updates };
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next); queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
}, },
@@ -17,7 +17,6 @@ interface UseProvidersSnapshotResult {
error: string | null; error: string | null;
supportsSnapshot: boolean; supportsSnapshot: boolean;
refresh: () => void; refresh: () => void;
invalidate: () => void;
} }
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult { export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
@@ -67,10 +66,6 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
void client.refreshProvidersSnapshot(); void client.refreshProvidersSnapshot();
}, [client]); }, [client]);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return { return {
entries: snapshotQuery.data?.entries ?? undefined, entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading, isLoading: snapshotQuery.isLoading,
@@ -78,7 +73,6 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot, supportsSnapshot,
refresh, refresh,
invalidate,
}; };
} }
+1 -12
View File
@@ -6,22 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-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 { export interface AppSettings {
theme: ThemeName | "auto"; theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
} }
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
sendBehavior: "interrupt",
}; };
export interface UseAppSettingsReturn { export interface UseAppSettingsReturn {
@@ -82,9 +74,6 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>; 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 }; return { ...DEFAULT_APP_SETTINGS, ...parsed };
} }
@@ -5,7 +5,6 @@ import type {
FetchAgentsEntry, FetchAgentsEntry,
FetchAgentsOptions, FetchAgentsOptions,
} from "@server/client/daemon-client"; } from "@server/client/daemon-client";
import type { ConnectionOffer } from "@server/shared/connection-offer";
import type { HostConnection, HostProfile } from "@/types/host-connection"; import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { import {
@@ -190,17 +189,6 @@ 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( function makeDeps(
latencyByConnectionId: Record<string, number | Error>, latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[], createdClients: FakeDaemonClient[],
@@ -1307,53 +1295,4 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]); 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([]);
});
}); });
+7 -17
View File
@@ -1287,22 +1287,15 @@ export class HostRuntimeStore {
}); });
} }
async upsertConnectionFromOffer( async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
offer: ConnectionOffer,
label?: string,
): Promise<HostProfile> {
return this.upsertRelayConnection({ return this.upsertRelayConnection({
serverId: offer.serverId, serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint, relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
}); });
} }
async upsertConnectionFromOfferUrl( async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
offerUrlOrFragment: string,
label?: string,
): Promise<HostProfile> {
const marker = "#offer="; const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker); const idx = offerUrlOrFragment.indexOf(marker);
if (idx === -1) { if (idx === -1) {
@@ -1314,7 +1307,7 @@ export class HostRuntimeStore {
} }
const payload = decodeOfferFragmentPayload(encoded); const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload); const offer = ConnectionOfferSchema.parse(payload);
return this.upsertConnectionFromOffer(offer, label); return this.upsertConnectionFromOffer(offer);
} }
async addConnectionFromListenAndWaitForOnline(input: { async addConnectionFromListenAndWaitForOnline(input: {
@@ -1963,11 +1956,8 @@ export interface HostMutations {
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
label?: string; label?: string;
}) => Promise<HostProfile>; }) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>; upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: ( upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
offerUrlOrFragment: string,
label?: string,
) => Promise<HostProfile>;
renameHost: (serverId: string, label: string) => Promise<void>; renameHost: (serverId: string, label: string) => Promise<void>;
removeHost: (serverId: string) => Promise<void>; removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>; removeConnection: (serverId: string, connectionId: string) => Promise<void>;
@@ -1979,8 +1969,8 @@ export function useHostMutations(): HostMutations {
() => ({ () => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input), upsertDirectConnection: (input) => store.upsertDirectConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input), upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
renameHost: (serverId, label) => store.renameHost(serverId, label), renameHost: (serverId, label) => store.renameHost(serverId, label),
removeHost: (serverId) => store.removeHost(serverId), removeHost: (serverId) => store.removeHost(serverId),
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
@@ -40,7 +40,7 @@ import {
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen"; import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
@@ -226,7 +226,6 @@ function DraftAgentScreenContent({
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser, setProviderAndModelFromUser,
persistFormPreferences, persistFormPreferences,
} = useAgentFormState({ } = useAgentFormState({
@@ -236,7 +235,7 @@ function DraftAgentScreenContent({
isCreateFlow: true, isCreateFlow: true,
onlineServerIds, onlineServerIds,
}); });
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1269,7 +1268,6 @@ function DraftAgentScreenContent({
onSelectThinkingOption: setThinkingOptionFromUser, onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures, features: draftFeatures,
onSetFeature: setDraftFeatureValue, onSetFeature: setDraftFeatureValue,
onModelSelectorOpen: invalidateProviderModels,
disabled: isSubmitting, disabled: isSubmitting,
}} }}
/> />
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) { 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 hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0); const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
useEffect(() => { useEffect(() => {
if (!isCompactLayout) { if (!isCompactLayout) {
+86 -143
View File
@@ -10,12 +10,12 @@ import {
Sun, Sun,
Moon, Moon,
Monitor, Monitor,
ChevronDown,
Globe, Globe,
Settings, Settings,
RotateCw, RotateCw,
Trash2, Trash2,
Server, Server,
Palette,
Keyboard, Keyboard,
Stethoscope, Stethoscope,
Info, Info,
@@ -24,8 +24,7 @@ import {
Blocks, Blocks,
Smartphone, Smartphone,
} from "lucide-react-native"; } from "lucide-react-native";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { THEME_SWATCHES, type ThemeName } from "@/styles/theme";
import type { HostProfile, HostConnection } from "@/types/host-connection"; import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -43,13 +42,13 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal"; import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal"; import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
@@ -66,7 +65,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
@@ -78,7 +77,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
type SettingsSectionId = type SettingsSectionId =
| "hosts" | "hosts"
| "general" | "appearance"
| "shortcuts" | "shortcuts"
| "integrations" | "integrations"
| "providers" | "providers"
@@ -97,21 +96,21 @@ interface SettingsSectionDef {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [ const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server }, { id: "hosts", label: "Hosts", icon: Server },
{ id: "general", label: "General", icon: Settings }, { id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "permissions", label: "Permissions", icon: Shield }, { id: "permissions", label: "Permissions", icon: Shield },
]; ];
if (context.isDesktopApp) { if (context.isDesktopApp) {
sections.push( sections.push(
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "integrations", label: "Integrations", icon: Puzzle }, { id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings }, { id: "daemon", label: "Daemon", icon: Settings },
{ id: "providers", label: "Providers", icon: Blocks },
); );
} }
sections.push( sections.push(
{ id: "providers", label: "Providers", icon: Blocks },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info }, { id: "about", label: "About", icon: Info },
); );
@@ -205,6 +204,10 @@ interface HostsSectionProps {
goBackToAddConnectionMethods: () => void; goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void; setIsDirectHostVisible: (visible: boolean) => void;
setIsPasteLinkVisible: (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; pendingRemoveHost: HostProfile | null;
setPendingRemoveHost: (host: HostProfile | null) => void; setPendingRemoveHost: (host: HostProfile | null) => void;
isRemovingHost: boolean; isRemovingHost: boolean;
@@ -287,14 +290,38 @@ function HostsSection(props: HostsSectionProps) {
visible={props.isDirectHostVisible} visible={props.isDirectHostVisible}
onClose={props.closeAddConnectionFlow} onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods} onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/> />
<PairLinkModal <PairLinkModal
visible={props.isPasteLinkVisible} visible={props.isPasteLinkVisible}
onClose={props.closeAddConnectionFlow} onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods} 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 ? ( {props.pendingRemoveHost ? (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Remove host" title="Remove host"
@@ -359,119 +386,41 @@ function HostsSection(props: HostsSectionProps) {
); );
} }
interface GeneralSectionProps { interface AppearanceSectionProps {
settings: AppSettings; settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void; handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
} }
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) { function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
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 ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>General</Text> <Text style={settingsStyles.sectionTitle}>Appearance</Text>
<View style={[settingsStyles.card, styles.audioCard]}> <View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}> <View style={styles.audioRow}>
<View style={styles.audioRowContent}> <View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Theme</Text> <Text style={styles.audioRowTitle}>Theme</Text>
</View> </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 <SegmentedControl
size="sm" size="sm"
value={settings.sendBehavior} hideLabels={Platform.OS !== "web"}
onValueChange={handleSendBehaviorChange} value={settings.theme}
onValueChange={handleThemeChange}
options={[ options={[
{ value: "interrupt", label: "Interrupt" }, {
{ value: "queue", label: "Queue" }, 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} />,
},
]} ]}
/> />
</View> </View>
@@ -665,7 +614,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
interface SettingsSectionContentProps { interface SettingsSectionContentProps {
sectionId: SettingsSectionId; sectionId: SettingsSectionId;
hostsProps: HostsSectionProps; hostsProps: HostsSectionProps;
generalProps: GeneralSectionProps; appearanceProps: AppearanceSectionProps;
providersProps: ProvidersSectionProps; providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps; diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps; aboutProps: AboutSectionProps;
@@ -677,7 +626,7 @@ interface SettingsSectionContentProps {
function SettingsSectionContent({ function SettingsSectionContent({
sectionId, sectionId,
hostsProps, hostsProps,
generalProps, appearanceProps,
providersProps, providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
@@ -688,8 +637,8 @@ function SettingsSectionContent({
switch (sectionId) { switch (sectionId) {
case "hosts": case "hosts":
return <HostsSection {...hostsProps} />; return <HostsSection {...hostsProps} />;
case "general": case "appearance":
return <GeneralSection {...generalProps} />; return <AppearanceSection {...appearanceProps} />;
case "shortcuts": case "shortcuts":
return <KeyboardShortcutsSection />; return <KeyboardShortcutsSection />;
case "providers": case "providers":
@@ -866,7 +815,7 @@ function DesktopAppUpdateRow() {
<Text style={styles.aboutHintText}>{statusText}</Text> <Text style={styles.aboutHintText}>{statusText}</Text>
{availableUpdate?.latestVersion ? ( {availableUpdate?.latestVersion ? (
<Text style={styles.aboutHintText}> <Text style={styles.aboutHintText}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text> </Text>
) : null} ) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
@@ -914,6 +863,10 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = 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 [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false); const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null); const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
@@ -931,6 +884,19 @@ export default function SettingsScreen() {
const editingDaemonLive = editingServerId const editingDaemonLive = editingServerId
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null) ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
: null; : null;
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1037,13 +1003,6 @@ export default function SettingsScreen() {
[updateSettings], [updateSettings],
); );
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => { const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) { if (!voiceAudioEngine || isPlaybackTestRunning) {
return; return;
@@ -1073,7 +1032,7 @@ export default function SettingsScreen() {
} }
}, [isPlaybackTestRunning, voiceAudioEngine]); }, [isPlaybackTestRunning, voiceAudioEngine]);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const sections = getSettingsSections({ isDesktopApp }); const sections = getSettingsSections({ isDesktopApp });
const hostsProps: HostsSectionProps = { const hostsProps: HostsSectionProps = {
@@ -1090,6 +1049,10 @@ export default function SettingsScreen() {
goBackToAddConnectionMethods, goBackToAddConnectionMethods,
setIsDirectHostVisible, setIsDirectHostVisible,
setIsPasteLinkVisible, setIsPasteLinkVisible,
pendingNameHost,
setPendingNameHost,
pendingNameHostname,
renameHost,
pendingRemoveHost, pendingRemoveHost,
setPendingRemoveHost, setPendingRemoveHost,
isRemovingHost, isRemovingHost,
@@ -1106,10 +1069,9 @@ export default function SettingsScreen() {
isMountedRef, isMountedRef,
}; };
const generalProps: GeneralSectionProps = { const appearanceProps: AppearanceSectionProps = {
settings, settings,
handleThemeChange, handleThemeChange,
handleSendBehaviorChange,
}; };
const diagnosticsProps: DiagnosticsSectionProps = { const diagnosticsProps: DiagnosticsSectionProps = {
@@ -1130,7 +1092,7 @@ export default function SettingsScreen() {
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = { const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps, hostsProps,
generalProps, appearanceProps,
providersProps, providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
@@ -1865,20 +1827,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, 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: { disabled: {
opacity: theme.opacity[50], opacity: theme.opacity[50],
}, },
@@ -1953,11 +1901,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
audioRowSubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: { providerActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -38,28 +38,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "flex-start", justifyContent: "flex-start",
paddingTop: theme.spacing[16], 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: { centeredContent: {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -277,73 +255,67 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
} }
return ( return (
<View style={styles.errorScreen}> <View style={[styles.container, styles.containerError]}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<ScrollView <View style={styles.errorContent}>
style={styles.errorScrollView} <View style={styles.errorHeader}>
contentContainerStyle={styles.errorScrollContent} <PaseoLogo size={64} />
showsVerticalScrollIndicator <Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
>
<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> </View>
</ScrollView>
<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>
</View> </View>
); );
} }
@@ -80,7 +80,6 @@ export function WorkspaceDraftAgentTab({
availableThinkingOptions, availableThinkingOptions,
isModelLoading, isModelLoading,
setProviderAndModelFromUser, setProviderAndModelFromUser,
invalidateProviderModels,
persistFormPreferences, persistFormPreferences,
} = useAgentFormState({ } = useAgentFormState({
initialServerId: serverId, initialServerId: serverId,
@@ -374,7 +373,6 @@ export function WorkspaceDraftAgentTab({
features: draftFeatures, features: draftFeatures,
onSetFeature: handleSetFeatureWithFocus, onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: () => focusInputRef.current?.(), onDropdownClose: () => focusInputRef.current?.(),
onModelSelectorOpen: invalidateProviderModels,
disabled: isSubmitting, disabled: isSubmitting,
}} }}
/> />
@@ -34,7 +34,6 @@ import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
import { BranchSwitcher } from "@/components/branch-switcher";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { import {
@@ -81,7 +80,6 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
@@ -118,7 +116,7 @@ import {
closeBulkWorkspaceTabs, closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close"; } from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -593,7 +591,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const mainBackgroundColor = theme.colors.surfaceWorkspace; const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = isCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
@@ -761,24 +759,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD" checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
? trimNonEmpty(checkoutQuery.data.currentBranch) ? trimNonEmpty(checkoutQuery.data.currentBranch)
: null; : 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 mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1968,14 +1948,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</> </>
) : ( ) : (
<> <>
<BranchSwitcher <Text
currentBranchName={currentBranchName} testID="workspace-header-title"
title={workspaceHeader.title} style={styles.headerTitle}
branchOptions={branchOptions} numberOfLines={1}
isOpen={isBranchSwitcherOpen} >
onOpenChange={setIsBranchSwitcherOpen} {workspaceHeader.title}
onBranchSelect={handleBranchSelect} </Text>
/>
<Text <Text
testID="workspace-header-subtitle" testID="workspace-header-subtitle"
style={styles.headerProjectTitle} style={styles.headerProjectTitle}
@@ -2371,12 +2350,12 @@ const styles = StyleSheet.create((theme) => ({
diffStatAdditions: { diffStatAdditions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition, color: theme.colors.palette.green[400],
}, },
diffStatDeletions: { diffStatDeletions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion, color: theme.colors.palette.red[500],
}, },
newTabActions: { newTabActions: {
flexDirection: "row", flexDirection: "row",
+97 -259
View File
@@ -103,19 +103,6 @@ export const baseColors = {
}, },
} as const; } 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 // Semantic color tokens - Layer-based system
const lightSemanticColors = { const lightSemanticColors = {
// Surfaces (layers) - shifted one step lighter // Surfaces (layers) - shifted one step lighter
@@ -126,11 +113,10 @@ const lightSemanticColors = {
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300) 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 surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode)
surfaceWorkspace: "#ffffff", // Workspace main background surfaceWorkspace: "#ffffff", // Workspace main background
// Text // Text
foreground: "#1a1a1e", foreground: "#09090b",
foregroundMuted: "#71717a", foregroundMuted: "#71717a",
// Controls // Controls
@@ -154,28 +140,26 @@ const lightSemanticColors = {
// Legacy aliases (for gradual migration) // Legacy aliases (for gradual migration)
background: "#ffffff", background: "#ffffff",
popover: "#ffffff", popover: "#ffffff",
popoverForeground: "#1a1a1e", popoverForeground: "#09090b",
primary: "#18181b", primary: "#18181b",
primaryForeground: "#fafafa", primaryForeground: "#fafafa",
secondary: "#f4f4f5", secondary: "#f4f4f5",
secondaryForeground: "#1a1a1e", secondaryForeground: "#09090b",
muted: "#f4f4f5", muted: "#f4f4f5",
mutedForeground: "#71717a", mutedForeground: "#71717a",
accentBorder: "#ececf1", accentBorder: "#ececf1",
input: "#f4f4f5", input: "#f4f4f5",
ring: "#18181b", ring: "#18181b",
...lightDiffColors,
terminal: { terminal: {
background: "#ffffff", background: "#ffffff",
foreground: "#1a1a1e", foreground: "#09090b",
cursor: "#1a1a1e", cursor: "#09090b",
cursorAccent: "#ffffff", cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)", selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#1a1a1e", selectionForeground: "#09090b",
black: "#1a1a1e", black: "#09090b",
red: "#dc2626", red: "#dc2626",
green: "#16a34a", green: "#16a34a",
yellow: "#ca8a04", yellow: "#ca8a04",
@@ -195,196 +179,80 @@ const lightSemanticColors = {
}, },
} as const; } as const;
// --------------------------------------------------------------------------- const darkSemanticColors = {
// Dark theme variant builder // 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)
interface DarkThemeConfig { // Text
surface0: string; foreground: "#fafafa",
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", foregroundMuted: "#A1A5A4",
scrollbarHandle: "#717574",
// Controls
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders
border: "#252B2A", border: "#252B2A",
borderAccent: "#2F3534", borderAccent: "#2F3534",
// Brand
accent: "#20744A", accent: "#20744A",
accentBright: "#7ccba0", accentBright: "#7ccba0",
}); accentForeground: "#ffffff",
// Zinc — neutral gray, no tint // Semantic
const zincDarkColors = buildDarkSemanticColors({ destructive: "#ef4444",
surface0: "#18181b", destructiveForeground: "#ffffff",
surface1: "#1f1f22", success: "#20744A",
surface2: "#27272a", successForeground: "#ffffff",
surface3: "#3f3f46",
surface4: "#52525b",
surfaceDiffEmpty: "#242427",
surfaceSidebar: "#131316",
surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa",
scrollbarHandle: "#71717a",
border: "#27272a",
borderAccent: "#303036",
accent: "#20744A",
accentBright: "#7ccba0",
});
// Midnight — subtle blue tint // Legacy aliases (for gradual migration)
const midnightDarkColors = buildDarkSemanticColors({ background: "#181B1A",
surface0: "#161820", popover: "#272A29",
surface1: "#1c1e27", popoverForeground: "#fafafa",
surface2: "#252731", primary: "#fafafa",
surface3: "#3c3e4c", primaryForeground: "#181B1A",
surface4: "#535564", secondary: "#272A29",
surfaceDiffEmpty: "#222430", secondaryForeground: "#fafafa",
surfaceSidebar: "#121420", muted: "#272A29",
surfaceSidebarHover: "#1a1c28", mutedForeground: "#A1A5A4",
foregroundMuted: "#9a9db0", accentBorder: "#2F3534",
scrollbarHandle: "#6b6e82", input: "#272A29",
border: "#242636", ring: "#d4d4d8",
borderAccent: "#2e3040",
accent: "#3b6fcf",
accentBright: "#7eaaeb",
});
// Claude — warm neutral with subtle orange undertone terminal: {
const claudeDarkColors = buildDarkSemanticColors({ background: "#181B1A",
surface0: "#1f1f1e", foreground: "#fafafa",
surface1: "#262523", cursor: "#fafafa",
surface2: "#2f2d2b", cursorAccent: "#181B1A",
surface3: "#4a4745", selectionBackground: "rgba(255, 255, 255, 0.2)",
surface4: "#605d5b", selectionForeground: "#fafafa",
surfaceDiffEmpty: "#2a2826",
surfaceSidebar: "#1a1918",
surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5",
scrollbarHandle: "#78746f",
border: "#2c2a27",
borderAccent: "#36332f",
accent: "#d97757",
accentBright: "#e89a7f",
});
// Ghostty — blue-tinted dark based on Ghostty default background black: "#141716",
const ghosttyDarkColors = buildDarkSemanticColors({ red: "#ef4444",
surface0: "#282c34", green: "#22c55e",
surface1: "#2f333d", yellow: "#f59e0b",
surface2: "#383c48", blue: "#3b82f6",
surface3: "#4a4f5e", magenta: "#a855f7",
surface4: "#5b6175", cyan: "#06b6d4",
surfaceDiffEmpty: "#323643", white: "#e4e4e7",
surfaceSidebar: "#21252d",
surfaceSidebarHover: "#292d36", brightBlack: "#434645",
foregroundMuted: "#c8ccd8", brightRed: "#f87171",
scrollbarHandle: "#a0a4b2", brightGreen: "#4ade80",
border: "#353a47", brightYellow: "#fbbf24",
borderAccent: "#3f4454", brightBlue: "#60a5fa",
accent: "#89b4fa", brightMagenta: "#c084fc",
accentBright: "#b4d0fc", brightCyan: "#22d3ee",
}); brightWhite: "#ffffff",
},
} as const;
const commonTheme = { const commonTheme = {
spacing: { spacing: {
@@ -455,44 +323,34 @@ const commonTheme = {
}, },
} as const; } as const;
const darkShadow = { export const darkTheme = {
sm: { colorScheme: "dark" as const,
shadowColor: "rgba(0, 0, 0, 0.25)", colors: {
shadowOffset: { width: 0, height: 2 }, ...darkSemanticColors,
shadowRadius: 4, palette: baseColors,
elevation: 2,
}, },
md: { shadow: {
shadowColor: "rgba(0, 0, 0, 0.20)", sm: {
shadowOffset: { width: 0, height: 4 }, shadowColor: "rgba(0, 0, 0, 0.25)",
shadowRadius: 8, shadowOffset: { width: 0, height: 2 },
elevation: 8, shadowRadius: 4,
}, elevation: 2,
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
} as const;
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
return {
colorScheme: "dark" as const,
colors: {
...semanticColors,
palette: baseColors,
}, },
shadow: darkShadow, md: {
...commonTheme, shadowColor: "rgba(0, 0, 0, 0.20)",
} as const; shadowOffset: { width: 0, height: 4 },
} shadowRadius: 8,
elevation: 8,
export const darkTheme = buildDarkTheme(paseoDarkColors); },
export const darkZincTheme = buildDarkTheme(zincDarkColors); lg: {
export const darkMidnightTheme = buildDarkTheme(midnightDarkColors); shadowColor: "rgba(0, 0, 0, 0.40)",
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors); shadowOffset: { width: 0, height: 12 },
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors); shadowRadius: 24,
elevation: 8,
},
},
...commonTheme,
} as const;
export const lightTheme = { export const lightTheme = {
colorScheme: "light" as const, colorScheme: "light" as const,
@@ -528,23 +386,3 @@ export const theme = darkTheme;
// Export a union type that works for both themes // Export a union type that works for both themes
export type Theme = typeof darkTheme | typeof lightTheme; 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",
};
+4 -16
View File
@@ -1,21 +1,11 @@
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { // import { UnistylesRuntime } from "react-native-unistyles";
lightTheme, import { lightTheme, darkTheme } from "./theme";
darkTheme,
darkZincTheme,
darkMidnightTheme,
darkClaudeTheme,
darkGhosttyTheme,
} from "./theme";
StyleSheet.configure({ StyleSheet.configure({
themes: { themes: {
light: lightTheme, light: lightTheme,
dark: darkTheme, dark: darkTheme,
darkZinc: darkZincTheme,
darkMidnight: darkMidnightTheme,
darkClaude: darkClaudeTheme,
darkGhostty: darkGhosttyTheme,
}, },
breakpoints: { breakpoints: {
xs: 0, xs: 0,
@@ -33,10 +23,6 @@ StyleSheet.configure({
type AppThemes = { type AppThemes = {
light: typeof lightTheme; light: typeof lightTheme;
dark: typeof darkTheme; dark: typeof darkTheme;
darkZinc: typeof darkZincTheme;
darkMidnight: typeof darkMidnightTheme;
darkClaude: typeof darkClaudeTheme;
darkGhostty: typeof darkGhosttyTheme;
}; };
type AppBreakpoints = { type AppBreakpoints = {
@@ -51,3 +37,5 @@ declare module "react-native-unistyles" {
export interface UnistylesThemes extends AppThemes {} export interface UnistylesThemes extends AppThemes {}
export interface UnistylesBreakpoints extends AppBreakpoints {} export interface UnistylesBreakpoints extends AppBreakpoints {}
} }
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);
+1 -74
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout"; import { buildSplitDiffRows } from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile { function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
@@ -79,76 +79,3 @@ 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);
});
});
-39
View File
@@ -7,12 +7,6 @@ export interface SplitDiffDisplayLine {
lineNumber: number | null; lineNumber: number | null;
} }
export interface UnifiedDiffDisplayLine {
key: string;
line: DiffLine;
lineNumber: number | null;
}
export type SplitDiffRow = export type SplitDiffRow =
| { | {
kind: "header"; kind: "header";
@@ -67,39 +61,6 @@ 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[] { export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = []; const rows: SplitDiffRow[] = [];
@@ -1,23 +0,0 @@
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);
});
});
-16
View File
@@ -1,16 +0,0 @@
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));
}
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
).toBe("attention"); ).toBe("attention");
}); });
it("treats initializing agents as done", () => { it("treats initializing agents as running", () => {
expect( expect(
deriveSidebarStateBucket({ deriveSidebarStateBucket({
status: "initializing", status: "initializing",
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
}), }),
).toBe("done"); ).toBe("running");
}); });
}); });
@@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: {
if (input.status === "error" || input.attentionReason === "error") { if (input.status === "error" || input.attentionReason === "error") {
return "failed"; return "failed";
} }
if (input.status === "running") { if (input.status === "running" || input.status === "initializing") {
return "running"; return "running";
} }
if (input.requiresAttention) { if (input.requiresAttention) {
+5 -13
View File
@@ -52,7 +52,7 @@ describe("terminal key helpers", () => {
}); });
}); });
it("only intercepts when pending modifiers are active", () => { it("intercepts special keys and modifier combos", () => {
expect( expect(
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "Escape", key: "Escape",
@@ -60,7 +60,7 @@ describe("terminal key helpers", () => {
altKey: false, altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false }, pendingModifiers: { ctrl: false, shift: false, alt: false },
}), }),
).toBe(false); ).toBe(true);
expect( expect(
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "c", key: "c",
@@ -68,23 +68,15 @@ describe("terminal key helpers", () => {
altKey: false, altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false }, pendingModifiers: { ctrl: false, shift: false, alt: false },
}), }),
).toBe(false); ).toBe(true);
expect( expect(
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "c", key: "c",
ctrlKey: false, ctrlKey: false,
altKey: false, altKey: false,
pendingModifiers: { ctrl: true, shift: false, alt: false }, pendingModifiers: { ctrl: false, shift: false, alt: false },
}), }),
).toBe(true); ).toBe(false);
expect(
shouldInterceptDomTerminalKey({
key: "Escape",
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: true },
}),
).toBe(true);
}); });
it("detects pending modifier state", () => { it("detects pending modifier state", () => {
+6 -1
View File
@@ -88,7 +88,12 @@ export function shouldInterceptDomTerminalKey(args: {
altKey: boolean; altKey: boolean;
pendingModifiers: PendingTerminalModifiers; pendingModifiers: PendingTerminalModifiers;
}): boolean { }): boolean {
return hasPendingTerminalModifiers(args.pendingModifiers); return (
args.key.length > 1 ||
args.ctrlKey ||
args.altKey ||
hasPendingTerminalModifiers(args.pendingModifiers)
);
} }
export function mergeTerminalModifiers(args: { export function mergeTerminalModifiers(args: {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.52", "version": "0.1.51-rc.1",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.52", "@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+4 -4
View File
@@ -1,19 +1,19 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.52", "version": "0.1.51-rc.1",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json", "build:main": "tsc -p tsconfig.json",
"dev": "./scripts/dev.sh", "dev": "npm run build:main && wait-on tcp:8081 && electron .",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.52", "@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.52", "@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
-28
View File
@@ -1,28 +0,0 @@
#!/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'"
+45 -127
View File
@@ -7,7 +7,6 @@ import { autoUpdater, type UpdateInfo } from "electron-updater";
export type AppUpdateCheckResult = { export type AppUpdateCheckResult = {
hasUpdate: boolean; hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string; currentVersion: string;
latestVersion: string; latestVersion: string;
body: string | null; body: string | null;
@@ -25,84 +24,19 @@ export type AppUpdateInstallResult = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let cachedUpdateInfo: UpdateInfo | null = null; let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false; let downloading = false;
let autoUpdaterConfigured = false;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Configuration // Configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function configureAutoUpdater(): void { function configureAutoUpdater(): void {
// Download updates in the background and only prompt once they are ready to install. // Don't auto-download — the user triggers install explicitly.
autoUpdater.autoDownload = true; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI. // Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true; 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);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -111,34 +45,28 @@ function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> { export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) { if (!app.isPackaged) {
return buildCheckResult({ return {
currentVersion,
hasUpdate: false, hasUpdate: false,
readyToInstall: false, currentVersion,
}); latestVersion: currentVersion,
body: null,
date: null,
};
} }
configureAutoUpdater(); configureAutoUpdater();
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try { try {
const result = await autoUpdater.checkForUpdates(); const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) { if (!result || !result.updateInfo) {
return buildCheckResult({ return {
currentVersion,
hasUpdate: false, hasUpdate: false,
readyToInstall: false, currentVersion,
}); latestVersion: currentVersion,
body: null,
date: null,
};
} }
const info = result.updateInfo; const info = result.updateInfo;
@@ -147,31 +75,24 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
if (hasUpdate) { if (hasUpdate) {
cachedUpdateInfo = info; cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
} }
cachedUpdateInfo = null; return {
downloadedUpdateVersion = null; hasUpdate,
downloading = false;
return buildCheckResult({
currentVersion, currentVersion,
hasUpdate: false, latestVersion,
readyToInstall: false, body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
}); date: typeof info.releaseDate === "string" ? info.releaseDate : null,
};
} catch (error) { } catch (error) {
console.error("[auto-updater] Failed to check for updates:", error); console.error("[auto-updater] Failed to check for updates:", error);
return buildCheckResult({ return {
currentVersion,
hasUpdate: false, hasUpdate: false,
readyToInstall: false, currentVersion,
}); latestVersion: currentVersion,
body: null,
date: null,
};
} }
} }
@@ -187,6 +108,14 @@ export async function downloadAndInstallUpdate(
}; };
} }
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update already in progress.",
};
}
if (!cachedUpdateInfo) { if (!cachedUpdateInfo) {
return { return {
installed: false, installed: false,
@@ -197,35 +126,24 @@ export async function downloadAndInstallUpdate(
configureAutoUpdater(); 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; downloading = true;
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion; // quitAndInstall restarts the app with the new version.
downloading = false; // Use a short delay to allow the renderer to receive the response.
scheduleQuitAndInstall(onBeforeQuit); setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
return { return {
installed: true, installed: true,
version: readyVersion, version: cachedUpdateInfo.version,
message: "Update downloaded. The app will restart shortly.", message: "Update downloaded. The app will restart shortly.",
}; };
} catch (error) { } catch (error) {
-38
View File
@@ -8,7 +8,6 @@ inheritLoginShellEnv();
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron"; import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { registerDaemonManager } from "./daemon/daemon-manager.js";
import { import {
@@ -39,37 +38,6 @@ const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project"; const OPEN_PROJECT_EVENT = "paseo:event:open-project";
app.setName("Paseo"); 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 // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
// Must run before app.whenReady(). // Must run before app.whenReady().
@@ -159,9 +127,7 @@ async function createMainWindow(): Promise<void> {
const iconPath = getWindowIconPath(); const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme(); const systemTheme = resolveSystemWindowTheme();
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
title,
width: 1200, width: 1200,
height: 800, height: 800,
show: false, show: false,
@@ -178,10 +144,6 @@ async function createMainWindow(): Promise<void> {
}, },
}); });
if (devWorktreeName) {
app.dock?.setBadge(devWorktreeName);
}
setupWindowResizeEvents(mainWindow); setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow); setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow); setupDragDropPrevention(mainWindow);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.52", "version": "0.1.51-rc.1",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.52", "version": "0.1.51-rc.1",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.52", "version": "0.1.51-rc.1",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
-3
View File
@@ -18,6 +18,3 @@ PASEO_LISTEN=127.0.0.1:6767
# Debug recordings (dictation + STT input + TTS output) # Debug recordings (dictation + STT input + TTS output)
# When enabled, recordings are saved under `${cwd}/.debug/recordings/` # When enabled, recordings are saved under `${cwd}/.debug/recordings/`
PASEO_DICTATION_DEBUG=1 PASEO_DICTATION_DEBUG=1
# Enable verbose Claude SDK stream logging (trace-level per-token logs)
PASEO_CLAUDE_DEBUG=1
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.52", "version": "0.1.51-rc.1",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.52", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.52", "@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -28,10 +28,6 @@ import type {
CheckoutPushResponse, CheckoutPushResponse,
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
CheckoutSwitchBranchResponse,
StashSaveResponse,
StashPopResponse,
StashListResponse,
ValidateBranchResponse, ValidateBranchResponse,
BranchSuggestionsResponse, BranchSuggestionsResponse,
DirectorySuggestionsResponse, DirectorySuggestionsResponse,
@@ -221,10 +217,6 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["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 ValidateBranchPayload = ValidateBranchResponse["payload"];
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
@@ -2382,74 +2374,6 @@ 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( async getPaseoWorktreeList(
input: { cwd?: string; repoRoot?: string }, input: { cwd?: string; repoRoot?: string },
requestId?: string, requestId?: string,
@@ -415,39 +415,10 @@ describe("transformPiModels", () => {
}); });
describe("ACPAgentSession slash commands", () => { describe("ACPAgentSession slash commands", () => {
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => { test("caches ACP available commands for listCommands", async () => {
const session = createSession(); const session = createSession();
await expect(session.listCommands()).resolves.toEqual([]); expect(await session.listCommands()).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({ (session as any).translateSessionUpdate({
sessionUpdate: "available_commands_update", sessionUpdate: "available_commands_update",
@@ -463,19 +434,6 @@ 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([ expect(await session.listCommands()).toEqual([
{ {
name: "research_codebase", name: "research_codebase",
@@ -125,8 +125,6 @@ type ACPAgentClientOptions = {
thinkingOptionId: string, thinkingOptionId: string,
) => Promise<void>; ) => Promise<void>;
capabilities?: AgentCapabilityFlags; capabilities?: AgentCapabilityFlags;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
}; };
type ACPAgentSessionOptions = { type ACPAgentSessionOptions = {
@@ -146,8 +144,6 @@ type ACPAgentSessionOptions = {
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle; handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>; launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
}; };
type SpawnedACPProcess = { type SpawnedACPProcess = {
@@ -306,8 +302,6 @@ export class ACPAgentClient implements AgentClient {
sessionId: string, sessionId: string,
thinkingOptionId: string, thinkingOptionId: string,
) => Promise<void>; ) => Promise<void>;
private readonly waitForInitialCommands: boolean;
private readonly initialCommandsWaitTimeoutMs: number;
constructor(options: ACPAgentClientOptions) { constructor(options: ACPAgentClientOptions) {
this.provider = options.provider; this.provider = options.provider;
@@ -320,8 +314,6 @@ export class ACPAgentClient implements AgentClient {
this.sessionResponseTransformer = options.sessionResponseTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.thinkingOptionWriter = options.thinkingOptionWriter; this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
} }
async createSession( async createSession(
@@ -343,8 +335,6 @@ export class ACPAgentClient implements AgentClient {
thinkingOptionWriter: this.thinkingOptionWriter, thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities, capabilities: this.capabilities,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
}, },
); );
await session.initializeNewSession(); await session.initializeNewSession();
@@ -385,8 +375,6 @@ export class ACPAgentClient implements AgentClient {
capabilities: this.capabilities, capabilities: this.capabilities,
handle, handle,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
}); });
await session.initializeResumedSession(); await session.initializeResumedSession();
return session; return session;
@@ -629,10 +617,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null; private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = []; private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = []; 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 currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private closed = false; private closed = false;
@@ -661,8 +645,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentModel = config.model ?? null; this.currentModel = config.model ?? null;
this.thinkingOptionId = config.thinkingOptionId ?? null; this.thinkingOptionId = config.thinkingOptionId ?? null;
this.currentTitle = config.title ?? null; this.currentTitle = config.title ?? null;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
} }
get id(): string | null { get id(): string | null {
@@ -894,59 +876,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode; 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[]> { 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; return this.cachedCommands;
} }
@@ -1116,8 +1046,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
this.closed = true; this.closed = true;
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) { for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } }); pending.resolve({ outcome: { outcome: "cancelled" } });
} }
@@ -1464,7 +1392,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
description: command.description, description: command.description,
argumentHint: "", argumentHint: "",
})); }));
this.settleCommandsReady();
return []; return [];
default: default:
return []; return [];
@@ -1047,8 +1047,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
}; };
} }
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
export class ClaudeAgentClient implements AgentClient { export class ClaudeAgentClient implements AgentClient {
readonly provider: "claude" = "claude"; readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES; readonly capabilities = CLAUDE_CAPABILITIES;
@@ -2456,17 +2454,15 @@ class ClaudeAgentSession implements AgentSession {
while (!this.closed && this.query === activeQuery) { while (!this.closed && this.query === activeQuery) {
try { try {
for await (const message of activeQuery) { for await (const message of activeQuery) {
if (claudeDebug) { this.logger.trace(
this.logger.trace( {
{ claudeSessionId: this.claudeSessionId,
claudeSessionId: this.claudeSessionId, messageType: message.type,
messageType: message.type, messageSubtype: "subtype" in message ? message.subtype : undefined,
messageSubtype: "subtype" in message ? message.subtype : undefined, messageUuid: "uuid" in message ? message.uuid : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined, },
}, "Claude query pump: raw SDK message",
"Claude query pump: raw SDK message", );
);
}
consecutiveInterruptAbortRecoveries = 0; consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) { if (await this.handleMissingResumedConversation(message, activeQuery)) {
return; return;
@@ -2542,16 +2538,14 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message); const identifiers = readEventIdentifiers(message);
if (claudeDebug) { this.logger.trace(
this.logger.trace( {
{ claudeSessionId: this.claudeSessionId,
claudeSessionId: this.claudeSessionId, messageType: message.type,
messageType: message.type, turnId,
turnId, },
}, "Claude query pump: SDK message",
"Claude query pump: SDK message", );
);
}
const messageEvents = this.translateMessageToEvents(message, { const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true, suppressAssistantText: true,
@@ -315,23 +315,6 @@ const hasOpenCode = isBinaryInstalled("opencode");
}); });
describe("OpenCode adapter context-window normalization", () => { 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", () => { test("preserves provider catalog context limit in model metadata", () => {
const definition = __openCodeInternals.buildOpenCodeModelDefinition( const definition = __openCodeInternals.buildOpenCodeModelDefinition(
{ id: "openai", name: "OpenAI" }, { id: "openai", name: "OpenAI" },
@@ -4,10 +4,8 @@ import {
createOpencodeClient, createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent, type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
type OpencodeClient, type OpencodeClient,
type Part as OpenCodePart, type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import net from "node:net"; import net from "node:net";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -496,66 +494,7 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
].some((value) => typeof value === "number" && Number.isFinite(value)); ].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 = { export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup, buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition, buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey, buildOpenCodeModelLookupKey,
@@ -1497,7 +1436,7 @@ class OpenCodeAgentSession implements AgentSession {
this.accumulatedUsage = this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}; contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt); const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model); const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId; const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined; const effectiveVariant = thinkingOptionId ?? undefined;
@@ -1923,6 +1862,15 @@ class OpenCodeAgentSession implements AgentSession {
this.activeForegroundTurnId = null; 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 { private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) { if (!trimmed.startsWith("/") || trimmed.length <= 1) {
@@ -298,8 +298,6 @@ export class PiACPAgentClient extends ACPAgentClient {
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId }); await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
}, },
capabilities: PI_CAPABILITIES, capabilities: PI_CAPABILITIES,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
}); });
} }
+1 -1
View File
@@ -254,7 +254,7 @@ export async function createPaseoDaemon(
app.use((req, res, next) => { app.use((req, res, next) => {
const origin = req.headers.origin; const origin = req.headers.origin;
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) { if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
@@ -14,14 +14,6 @@ import {
} from "./test-utils/index.js"; } from "./test-utils/index.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.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; const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
@@ -102,116 +94,6 @@ 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", () => { describe("daemon client E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -397,38 +279,6 @@ describe("daemon client E2E", () => {
} }
}, 120000); }, 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 () => { test("resume_agent auto-unarchives archived agents", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
try { try {
+1 -1
View File
@@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info"; const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty"; const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_FILE_LEVEL: LogLevel = "debug"; const DEFAULT_FILE_LEVEL: LogLevel = "trace";
const DEFAULT_FILE_ROTATE_SIZE = "10m"; const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2; const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
+54 -151
View File
@@ -231,6 +231,8 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; 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; const MAX_TERMINAL_STREAM_SLOTS = 256;
function deriveInitialAgentTitle(prompt: string): string | null { function deriveInitialAgentTitle(prompt: string): string | null {
@@ -294,6 +296,7 @@ type ActiveTerminalStream = {
slot: number; slot: number;
unsubscribe: () => void; unsubscribe: () => void;
needsSnapshot: boolean; needsSnapshot: boolean;
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
}; };
export type SessionRuntimeMetrics = { export type SessionRuntimeMetrics = {
@@ -405,6 +408,7 @@ export type SessionOptions = {
appVersion: string | null; appVersion: string | null;
onMessage: (msg: SessionOutboundMessage) => void; onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
logger: pino.Logger; logger: pino.Logger;
downloadTokenStore: DownloadTokenStore; downloadTokenStore: DownloadTokenStore;
@@ -559,6 +563,7 @@ export class Session {
private readonly sessionId: string; private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getBinaryBufferedAmount: (() => number) | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string; private readonly paseoHome: string;
@@ -657,6 +662,7 @@ export class Session {
appVersion, appVersion,
onMessage, onMessage,
onBinaryMessage, onBinaryMessage,
getBinaryBufferedAmount,
onLifecycleIntent, onLifecycleIntent,
logger, logger,
downloadTokenStore, downloadTokenStore,
@@ -686,6 +692,7 @@ export class Session {
this.sessionId = uuidv4(); this.sessionId = uuidv4();
this.onMessage = onMessage; this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null; this.onBinaryMessage = onBinaryMessage ?? null;
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore; this.pushTokenStore = pushTokenStore;
@@ -1739,22 +1746,6 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg); this.handleUnsubscribeCheckoutDiffRequest(msg);
break; 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": case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg); await this.handleCheckoutCommitRequest(msg);
break; break;
@@ -3104,7 +3095,11 @@ export class Session {
const existing = this.agentManager.getAgent(agentId); const existing = this.agentManager.getAgent(agentId);
if (existing) { if (existing) {
await this.interruptAgentIfRunning(agentId); await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.reloadAgentSession(agentId); if (existing.persistence) {
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
snapshot = existing;
}
} else { } else {
const record = await this.agentStorage.get(agentId); const record = await this.agentStorage.get(agentId);
if (!record) { if (!record) {
@@ -4436,139 +4431,6 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId); 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( private async handleCheckoutCommitRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
): Promise<void> { ): Promise<void> {
@@ -5464,7 +5326,7 @@ export class Session {
if (agent.status === "error" || agent.attentionReason === "error") { if (agent.status === "error" || agent.attentionReason === "error") {
return "failed"; return "failed";
} }
if (agent.status === "running") { if (agent.status === "running" || agent.status === "initializing") {
return "running"; return "running";
} }
if (agent.requiresAttention) { if (agent.requiresAttention) {
@@ -8417,6 +8279,7 @@ export class Session {
slot, slot,
unsubscribe: () => {}, unsubscribe: () => {},
needsSnapshot: true, needsSnapshot: true,
snapshotRetryTimer: null,
}; };
this.activeTerminalStreams.set(slot, activeStream); this.activeTerminalStreams.set(slot, activeStream);
@@ -8433,6 +8296,10 @@ export class Session {
if (activeStream.needsSnapshot || message.data.length === 0) { if (activeStream.needsSnapshot || message.data.length === 0) {
return; return;
} }
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
this.markAllActiveTerminalStreamsForSnapshot();
return;
}
this.emitBinary( this.emitBinary(
encodeTerminalStreamFrame({ encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output, opcode: TerminalStreamOpcode.Output,
@@ -8440,6 +8307,9 @@ export class Session {
payload: new Uint8Array(Buffer.from(message.data, "utf8")), payload: new Uint8Array(Buffer.from(message.data, "utf8")),
}), }),
); );
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
this.markAllActiveTerminalStreamsForSnapshot();
}
}); });
return slot; return slot;
} }
@@ -8452,6 +8322,21 @@ export class Session {
return; 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); const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
if (!terminal) { if (!terminal) {
this.detachTerminalStream(activeStream.terminalId, { emitExit: true }); this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
@@ -8468,6 +8353,13 @@ export class Session {
); );
} }
private markAllActiveTerminalStreamsForSnapshot(): void {
for (const activeStream of this.activeTerminalStreams.values()) {
activeStream.needsSnapshot = true;
this.trySendTerminalSnapshot(activeStream);
}
}
private allocateTerminalSlot(): number | null { private allocateTerminalSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) { for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS; const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
@@ -8492,6 +8384,10 @@ export class Session {
} }
this.activeTerminalStreams.delete(slot); this.activeTerminalStreams.delete(slot);
this.terminalIdToSlot.delete(terminalId); this.terminalIdToSlot.delete(terminalId);
if (activeStream.snapshotRetryTimer) {
clearTimeout(activeStream.snapshotRetryTimer);
activeStream.snapshotRetryTimer = null;
}
try { try {
activeStream.unsubscribe(); activeStream.unsubscribe();
} catch (error) { } catch (error) {
@@ -8514,4 +8410,11 @@ export class Session {
} }
} }
private getCurrentBinaryBufferedAmount(): number {
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
return 0;
}
return Math.floor(bufferedAmount);
}
} }
+11 -1
View File
@@ -396,7 +396,7 @@ export class VoiceAssistantWebSocketServer {
!!requestHost && !!requestHost &&
(origin === `http://${requestHost}` || origin === `https://${requestHost}`); (origin === `http://${requestHost}` || origin === `https://${requestHost}`);
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) { if (!origin || allowedOrigins.has(origin) || sameOrigin) {
callback(true); callback(true);
} else { } else {
this.incrementRuntimeCounter("originRejected"); this.incrementRuntimeCounter("originRejected");
@@ -634,6 +634,16 @@ export class VoiceAssistantWebSocketServer {
} }
this.sendBinaryToConnection(connection, frame); 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) => { onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent); this.onLifecycleIntent?.(intent);
}, },
-96
View File
@@ -1057,37 +1057,6 @@ export const ValidateBranchRequestSchema = z.object({
requestId: z.string(), 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({ export const BranchSuggestionsRequestSchema = z.object({
type: z.literal("branch_suggestions_request"), type: z.literal("branch_suggestions_request"),
cwd: z.string(), cwd: z.string(),
@@ -1423,10 +1392,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema, CheckoutPrStatusRequestSchema,
CheckoutSwitchBranchRequestSchema,
StashSaveRequestSchema,
StashPopRequestSchema,
StashListRequestSchema,
ValidateBranchRequestSchema, ValidateBranchRequestSchema,
BranchSuggestionsRequestSchema, BranchSuggestionsRequestSchema,
DirectorySuggestionsRequestSchema, DirectorySuggestionsRequestSchema,
@@ -2227,54 +2192,6 @@ 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({ export const ValidateBranchResponseSchema = z.object({
type: z.literal("validate_branch_response"), type: z.literal("validate_branch_response"),
payload: z.object({ payload: z.object({
@@ -2661,10 +2578,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
CheckoutSwitchBranchResponseSchema,
StashSaveResponseSchema,
StashPopResponseSchema,
StashListResponseSchema,
ValidateBranchResponseSchema, ValidateBranchResponseSchema,
BranchSuggestionsResponseSchema, BranchSuggestionsResponseSchema,
DirectorySuggestionsResponseSchema, DirectorySuggestionsResponseSchema,
@@ -2880,15 +2793,6 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>; export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>; export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>; 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 ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>; export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>; export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
+45 -53
View File
@@ -157,6 +157,7 @@ type CheckoutFileChange = {
isUntracked?: boolean; isUntracked?: boolean;
}; };
type BranchSuggestionRefOrigin = "local" | "remote";
function normalizeBranchSuggestionName(raw: string): string | null { function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -175,57 +176,47 @@ function normalizeBranchSuggestionName(raw: string): string | null {
normalized = normalized.slice("origin/".length); normalized = normalized.slice("origin/".length);
} }
if (!normalized || normalized === "HEAD" || normalized === "origin") { if (!normalized || normalized === "HEAD") {
return null; return null;
} }
return normalized; return normalized;
} }
interface GitRef { async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
name: string; const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
committerDate: number; cwd,
} env: READ_ONLY_GIT_ENV,
});
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 return stdout
.split("\n") .split("\n")
.map((line) => { .map((line) => line.trim())
const trimmed = line.trim(); .filter((line) => line.length > 0);
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( function sortBranchSuggestions(
branchNames: string[], branchNames: string[],
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>, localBranchNames: Set<string>,
query: string, query: string,
): string[] { ): string[] {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const hasQuery = normalizedQuery.length > 0; const hasQuery = normalizedQuery.length > 0;
return branchNames.sort((a, b) => { return branchNames.sort((a, b) => {
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
if (hasQuery) { if (hasQuery) {
const aPrefix = a.toLowerCase().startsWith(normalizedQuery); const aPrefix = aLower.startsWith(normalizedQuery);
const bPrefix = b.toLowerCase().startsWith(normalizedQuery); const bPrefix = bLower.startsWith(normalizedQuery);
if (aPrefix !== bPrefix) { if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1; return aPrefix ? -1 : 1;
} }
} }
const aMeta = branchMeta.get(a); const aIsLocal = localBranchNames.has(a);
const bMeta = branchMeta.get(b); const bIsLocal = localBranchNames.has(b);
const aDate = aMeta?.committerDate ?? 0; if (aIsLocal !== bIsLocal) {
const bDate = bMeta?.committerDate ?? 0; return aIsLocal ? -1 : 1;
if (aDate !== bDate) {
return bDate - aDate;
} }
return a.localeCompare(b); return a.localeCompare(b);
@@ -247,40 +238,41 @@ export async function listBranchSuggestions(
listGitRefs(cwd, "refs/remotes/origin"), listGitRefs(cwd, "refs/remotes/origin"),
]); ]);
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>(); const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
for (const localRef of localRefs) {
for (const ref of localRefs) { const normalized = normalizeBranchSuggestionName(localRef);
const normalized = normalizeBranchSuggestionName(ref.name); if (!normalized) {
if (!normalized) continue; continue;
const existing = branchMeta.get(normalized);
branchMeta.set(normalized, {
isLocal: true,
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
});
}
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 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 filteredNames = Array.from(branchMeta.keys()).filter((name) => const filteredNames = Array.from(merged.keys()).filter((name) =>
query ? name.toLowerCase().includes(query) : true, query ? name.toLowerCase().includes(query) : true,
); );
if (filteredNames.length === 0) { if (filteredNames.length === 0) {
return []; return [];
} }
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); const localBranchNames = new Set<string>();
for (const [name, origins] of merged) {
if (origins.has("local")) {
localBranchNames.add(name);
}
}
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
return ordered.slice(0, limit); return ordered.slice(0, limit);
} }

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