mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
37 Commits
add-websto
...
v0.1.52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05734e8b1b | ||
|
|
c2f3bb73a6 | ||
|
|
1ea5e5a769 | ||
|
|
49e67363b2 | ||
|
|
b9a8ba054d | ||
|
|
2f77674c55 | ||
|
|
120c1b46a4 | ||
|
|
033c4bcbf2 | ||
|
|
0f005172ce | ||
|
|
0e7dfcaf2a | ||
|
|
ebfad945e5 | ||
|
|
b5a0ee9954 | ||
|
|
8a9d738438 | ||
|
|
1d41a50e1f | ||
|
|
32fe4b3beb | ||
|
|
a692c616cb | ||
|
|
edd5503fe8 | ||
|
|
34bd8dfd1b | ||
|
|
201eb6a671 | ||
|
|
9f09a19a09 | ||
|
|
edfd2564a3 | ||
|
|
c1ebb8c915 | ||
|
|
29af07e383 | ||
|
|
17bd036359 | ||
|
|
2683dae5f9 | ||
|
|
914a5dc6ae | ||
|
|
30842398e7 | ||
|
|
b27c1b0729 | ||
|
|
fcf2c14485 | ||
|
|
57eafda6ef | ||
|
|
4925b94743 | ||
|
|
f0b09d90fd | ||
|
|
bf91ea2cc9 | ||
|
|
f83a4d08da | ||
|
|
83f540f10a | ||
|
|
602b548745 | ||
|
|
d98d5457ed |
171
.github/workflows/ci.yml
vendored
Normal file
171
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Check formatting
|
||||
run: npx biome format .
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run server tests
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
||||
PASEO_DICTATION_ENABLED: '0'
|
||||
PASEO_VOICE_MODE_ENABLED: '0'
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.52 - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
||||
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
||||
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
||||
|
||||
### Fixed
|
||||
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
||||
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
||||
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
||||
- Initializing agents no longer show a loading spinner as if they're running.
|
||||
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
||||
- Error screens on desktop are now scrollable.
|
||||
- Model list refreshes in the background when you open the model selector.
|
||||
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
||||
|
||||
## 0.1.51 - 2026-04-09
|
||||
|
||||
### Added
|
||||
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
||||
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
||||
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
||||
|
||||
### Fixed
|
||||
- Model selector no longer crashes on iPad.
|
||||
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
||||
- OpenCode agents show the correct terminal state and refresh models reliably.
|
||||
- Follow-up messages to agents that just finished a turn now work correctly.
|
||||
- Commands now load properly for Pi agents.
|
||||
- Internal debug output no longer appears in Claude agent timelines.
|
||||
- QR scan screen cleaned up with simpler visuals.
|
||||
|
||||
## 0.1.50 - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
168
CONTRIBUTING.md
Normal file
168
CONTRIBUTING.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
|
||||
## How this project works
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
|
||||
This means:
|
||||
|
||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||
2. **Keep it small.** One bug, one flow, one focused change.
|
||||
3. **Open a PR** once there is alignment on scope.
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
||||
- [docs/TESTING.md](docs/TESTING.md)
|
||||
- [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
## What is most helpful
|
||||
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Scope expectations
|
||||
|
||||
Please keep PRs narrow.
|
||||
|
||||
Good:
|
||||
|
||||
- fix one bug
|
||||
- improve one flow
|
||||
- add one focused panel or command
|
||||
- tighten one piece of UI
|
||||
|
||||
Bad:
|
||||
|
||||
- combine multiple product ideas in one PR
|
||||
- bundle unrelated refactors with a feature
|
||||
- sneak in roadmap decisions
|
||||
|
||||
If a contribution contains multiple ideas, split it up.
|
||||
|
||||
## Product fit matters
|
||||
|
||||
Paseo is an opinionated product.
|
||||
|
||||
When reviewing contributions, the bar is not just:
|
||||
|
||||
- is this useful?
|
||||
- is this well implemented?
|
||||
|
||||
It is also:
|
||||
|
||||
- does this fit Paseo?
|
||||
- does this add product surface that will be hard to maintain?
|
||||
- does the value justify the maintenance surface it adds?
|
||||
- does this solve a common need or over-serve an edge case?
|
||||
- does this preserve the product's current direction?
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js matching `.tool-versions`
|
||||
- npm workspaces
|
||||
|
||||
### Start local development
|
||||
|
||||
```bash
|
||||
# runs both daemon and expo app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
npm run cli -- ls -a -g
|
||||
```
|
||||
|
||||
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
|
||||
## Multi-platform testing
|
||||
|
||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspaces --if-present
|
||||
```
|
||||
|
||||
Important rules:
|
||||
|
||||
- always run `npm run typecheck` after changes
|
||||
- tests should be deterministic
|
||||
- prefer real dependencies over mocks when possible
|
||||
- do not make breaking WebSocket / protocol changes
|
||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||
|
||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Coding standards
|
||||
|
||||
Paseo has explicit standards. Follow them.
|
||||
|
||||
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
||||
|
||||
## PR checklist
|
||||
|
||||
Before opening a PR, make sure:
|
||||
|
||||
- there was prior discussion and alignment on scope (issue or conversation)
|
||||
- the change is focused, one idea per PR
|
||||
- the PR description explains what changed and why
|
||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||
- UI changes have been tested on mobile and web at minimum
|
||||
- typecheck passes
|
||||
- tests pass, or you clearly explain what could not be run
|
||||
- relevant docs were updated if needed
|
||||
|
||||
## Communication
|
||||
|
||||
If you are unsure whether something fits, ask first.
|
||||
|
||||
That is especially true for:
|
||||
|
||||
- new core UX
|
||||
- naming / terminology changes
|
||||
- new extension points
|
||||
- new orchestration models
|
||||
- anything that would be hard to remove later
|
||||
|
||||
Early alignment saves everyone time.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, a fork is completely fine.
|
||||
|
||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||
428
docs/DATA_MODEL.md
Normal file
428
docs/DATA_MODEL.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Data Model
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── config.json # Daemon configuration
|
||||
├── agents/
|
||||
│ └── {project-dir}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── chat/
|
||||
│ └── rooms.json # All rooms + messages
|
||||
├── loops/
|
||||
│ └── loops.json # All loop records
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ └── workspaces.json # Workspace registry
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
|
||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `title` | `string?` | Configured title |
|
||||
| `modeId` | `string?` | Configured mode |
|
||||
| `model` | `string?` | Configured model |
|
||||
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
||||
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
||||
| `extra` | `Record<string, any>?` | Provider-specific config |
|
||||
| `systemPrompt` | `string?` | Custom system prompt |
|
||||
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
||||
|
||||
### Nested: RuntimeInfo
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `provider` | `string` | Active provider |
|
||||
| `sessionId` | `string?` | Active session ID |
|
||||
| `model` | `string?` | Active model |
|
||||
| `thinkingOptionId` | `string?` | Active thinking option |
|
||||
| `modeId` | `string?` | Active mode |
|
||||
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
||||
|
||||
### Nested: PersistenceHandle
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `provider` | `string` | Provider that owns the session |
|
||||
| `sessionId` | `string` | Session ID for resumption |
|
||||
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
||||
| `metadata` | `Record<string, any>?` | Extra metadata |
|
||||
|
||||
### Nested: AgentFeature (discriminated union on `type`)
|
||||
|
||||
**Toggle:**
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `type` | `"toggle"` |
|
||||
| `id` | `string` |
|
||||
| `label` | `string` |
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `boolean` |
|
||||
|
||||
**Select:**
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `type` | `"select"` |
|
||||
| `id` | `string` |
|
||||
| `label` | `string` |
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string?` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Daemon Configuration
|
||||
|
||||
**Path:** `$PASEO_HOME/config.json`
|
||||
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
```
|
||||
{
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
allowedHosts: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
}
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
console: { level, format },
|
||||
file: { level, path, rotate: { maxSize, maxFiles } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | 8-char hex ID |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | The prompt to send |
|
||||
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
||||
| `target` | `ScheduleTarget` | What to run (see below) |
|
||||
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
||||
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
||||
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
||||
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
||||
| `maxRuns` | `number?` | Max executions before completing |
|
||||
| `runs` | `ScheduleRun[]` | Execution history |
|
||||
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string }` — cron expression
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
|
||||
### Nested: ScheduleRun
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Run ID |
|
||||
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `endedAt` | `string?` (ISO 8601) | |
|
||||
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
||||
| `agentId` | `string?` (UUID) | Agent used for this run |
|
||||
| `output` | `string?` | Agent output text |
|
||||
| `error` | `string?` | Error message if failed |
|
||||
|
||||
---
|
||||
|
||||
## 4. Chat
|
||||
|
||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||
|
||||
Single file containing all rooms and messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [ ... ],
|
||||
"messages": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### ChatRoom
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` (UUID) | |
|
||||
| `name` | `string` | Unique room name (case-insensitive) |
|
||||
| `purpose` | `string?` | Room description |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||
|
||||
### ChatMessage
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` (UUID) | |
|
||||
| `roomId` | `string` | FK to ChatRoom.id |
|
||||
| `authorAgentId` | `string` | Agent ID of the author |
|
||||
| `body` | `string` | Message text (supports `@mentions`) |
|
||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | 8-char UUID prefix |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | Worker prompt |
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||
| `maxIterations` | `number?` | Cap on iterations |
|
||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `completedAt` | `string?` (ISO 8601) | |
|
||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||
| `iterations` | `LoopIteration[]` | |
|
||||
| `logs` | `LoopLogEntry[]` | |
|
||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||
|
||||
### Nested: LoopIteration
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `index` | `number` | 1-based iteration index |
|
||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||
| `failureReason` | `string?` | |
|
||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||
|
||||
### Nested: LoopLogEntry
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `seq` | `number` (monotonic) |
|
||||
| `timestamp` | `string` (ISO 8601) |
|
||||
| `iteration` | `number?` |
|
||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||
| `level` | `"info" \| "error"` |
|
||||
| `text` | `string` |
|
||||
|
||||
### Nested: LoopVerifyCheckResult
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `command` | `string` |
|
||||
| `exitCode` | `number` |
|
||||
| `passed` | `boolean` |
|
||||
| `stdout` | `string` |
|
||||
| `stderr` | `string` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
### Nested: LoopVerifyPromptResult
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `passed` | `boolean` |
|
||||
| `reason` | `string` |
|
||||
| `verifierAgentId` | `string?` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Project Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": ["ExponentPushToken[...]", ...]
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
|
||||
---
|
||||
|
||||
## Client-side stores (App)
|
||||
|
||||
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
||||
|
||||
### Draft Store
|
||||
|
||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||
|
||||
```typescript
|
||||
{
|
||||
drafts: Record<draftKey, {
|
||||
input: { text: string, images: AttachmentMetadata[] },
|
||||
lifecycle: "active" | "abandoned" | "sent",
|
||||
updatedAt: number, // epoch ms
|
||||
version: number // optimistic concurrency
|
||||
}>,
|
||||
createModalDraft: DraftRecord | null
|
||||
}
|
||||
```
|
||||
|
||||
### Attachment Store (Web)
|
||||
|
||||
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
||||
|
||||
Stores binary attachment blobs keyed by attachment ID.
|
||||
|
||||
### AttachmentMetadata
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Unique attachment ID |
|
||||
| `mimeType` | `string` | MIME type |
|
||||
| `storageType` | `string` | Storage backend identifier |
|
||||
| `storageKey` | `string` | Key within the storage backend |
|
||||
| `createdAt` | `number` | Epoch ms |
|
||||
| `fileName` | `string?` | Original filename |
|
||||
| `byteSize` | `number?` | Size in bytes |
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||
npmDepsHash = "sha256-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35032,11 +35032,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35077,11 +35077,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/cli": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35115,7 +35115,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35316,7 +35316,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35342,7 +35342,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35358,14 +35358,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35764,7 +35764,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
@@ -57,7 +58,7 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
@@ -372,7 +373,7 @@ function AppContainer({
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -561,7 +562,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
} else {
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(settings.theme);
|
||||
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult } from "expo-camera";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
@@ -61,7 +59,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "absolute",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderColor: theme.colors.palette.blue[400],
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
cornerTL: {
|
||||
left: 0,
|
||||
@@ -148,28 +146,11 @@ export default function PairScanScreen() {
|
||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -224,7 +205,6 @@ export default function PairScanScreen() {
|
||||
|
||||
const handleScan = useCallback(
|
||||
async (result: BarcodeScanningResult) => {
|
||||
if (pendingNameHost) return;
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
@@ -248,7 +228,7 @@ export default function PairScanScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { client } = await connectToDaemon(
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
@@ -259,13 +239,7 @@ export default function PairScanScreen() {
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
||||
return;
|
||||
}
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||
|
||||
returnToSource(profile.serverId);
|
||||
} catch (error) {
|
||||
@@ -276,7 +250,7 @@ export default function PairScanScreen() {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
@@ -307,25 +281,6 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{pendingNameHost ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={pendingNameHost.serverId}
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
void renameHost(serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||
<Pressable onPress={closeToSource}>
|
||||
@@ -359,7 +314,6 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||
{isPairing ? (
|
||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||
Pairing…
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
@@ -119,7 +120,7 @@ export function AdaptiveModalSheet({
|
||||
testID,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const dismissingForVisibilityRef = useRef(false);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -239,7 +240,7 @@ export function AdaptiveModalSheet({
|
||||
*/
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
if (isMobile) {
|
||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
@@ -151,7 +152,7 @@ export function AddHostModal({
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { upsertDirectConnection } = useHostMutations();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const hostInputRef = useRef<TextInput>(null);
|
||||
|
||||
@@ -218,6 +219,7 @@ export function AddHostModal({
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
label: hostname ?? undefined,
|
||||
});
|
||||
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
@@ -48,6 +49,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -131,6 +133,8 @@ export function AgentInputArea({
|
||||
agentDirectoryStatus === "revalidating" ||
|
||||
agentDirectoryStatus === "error_after_ready");
|
||||
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
@@ -151,10 +155,8 @@ export function AgentInputArea({
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isDesktopWebBreakpoint =
|
||||
Platform.OS === "web" &&
|
||||
UnistylesRuntime.breakpoint !== "xs" &&
|
||||
UnistylesRuntime.breakpoint !== "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
|
||||
const messagePlaceholder = isDesktopWebBreakpoint
|
||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||
@@ -742,6 +744,7 @@ export function AgentInputArea({
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
@@ -214,7 +215,7 @@ export function AgentList({
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
|
||||
@@ -88,6 +88,7 @@ type ControlledAgentStatusBarProps = {
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -110,6 +111,7 @@ export interface DraftAgentStatusBarProps {
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -217,6 +219,7 @@ function ControlledStatusBar({
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -411,6 +414,7 @@ function ControlledStatusBar({
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
</View>
|
||||
@@ -662,6 +666,7 @@ function ControlledStatusBar({
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
@@ -875,6 +880,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
invalidate: invalidateSnapshot,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
@@ -962,13 +968,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
return;
|
||||
}
|
||||
void updatePreferences(
|
||||
mergeProviderPreferences({
|
||||
preferences,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist model preference failed", error);
|
||||
});
|
||||
@@ -978,7 +985,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
@@ -991,16 +998,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
const activeModelId = modelSelection.activeModelId;
|
||||
if (activeModelId) {
|
||||
void updatePreferences(
|
||||
mergeProviderPreferences({
|
||||
preferences,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: activeModelId,
|
||||
thinkingByModel: {
|
||||
[activeModelId]: thinkingOptionId,
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: activeModelId,
|
||||
thinkingByModel: {
|
||||
[activeModelId]: thinkingOptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist thinking preference failed", error);
|
||||
});
|
||||
@@ -1014,11 +1022,26 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences(
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
featureValues: {
|
||||
[featureId]: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist feature preference failed", error);
|
||||
});
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onModelSelectorOpen={invalidateSnapshot}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1045,6 +1068,7 @@ export function DraftAgentStatusBar({
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -1083,12 +1107,13 @@ export function DraftAgentStatusBar({
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
@@ -1129,7 +1154,7 @@ export function DraftAgentStatusBar({
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
@@ -1138,6 +1163,7 @@ export function DraftAgentStatusBar({
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import Animated, {
|
||||
@@ -109,7 +110,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
() =>
|
||||
resolveStreamRenderStrategy({
|
||||
@@ -707,7 +708,7 @@ function PermissionRequestCard({
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const { request } = permission;
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
|
||||
125
packages/app/src/components/branch-switcher.tsx
Normal file
125
packages/app/src/components/branch-switcher.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
title: string;
|
||||
branchOptions: ComboboxOption[];
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBranchSelect: (branchId: string) => void;
|
||||
}
|
||||
|
||||
export function BranchSwitcher({
|
||||
currentBranchName,
|
||||
title,
|
||||
branchOptions,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onBranchSelect,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return (
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
testID="workspace-header-branch-switcher"
|
||||
onPress={() => onOpenChange(true)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
value={currentBranchName}
|
||||
onSelect={onBranchSelect}
|
||||
searchable
|
||||
placeholder="Switch branch..."
|
||||
searchPlaceholder="Filter branches..."
|
||||
emptyText="No branches found."
|
||||
title="Switch branch"
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
desktopMinWidth={280}
|
||||
renderOption={({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
md: "300",
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
branchSwitcherTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchSwitcherTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
}));
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
@@ -58,6 +59,7 @@ interface CombinedModelSelectorProps {
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -348,7 +350,8 @@ function ProviderSearchInput({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||
@@ -515,6 +518,7 @@ export function CombinedModelSelector({
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
onOpen,
|
||||
onClose,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
@@ -539,12 +543,14 @@ export function CombinedModelSelector({
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
setView(singleProviderView ?? { kind: "all" });
|
||||
if (!open) {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
setSearchQuery("");
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onClose, singleProviderView],
|
||||
[onOpen, onClose, singleProviderView],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -48,7 +48,7 @@ export function ExplorerSidebar({
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
@@ -89,7 +90,7 @@ export function FileExplorerPane({
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const daemons = useHosts();
|
||||
@@ -848,7 +849,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
indentGuide: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
@@ -241,7 +242,7 @@ export function FilePane({
|
||||
workspaceRoot: string;
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
@@ -56,7 +57,12 @@ import {
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
type SplitDiffDisplayLine,
|
||||
type SplitDiffRow,
|
||||
} from "@/utils/diff-layout";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -74,6 +80,11 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
hasVisibleDiffTokens,
|
||||
} from "@/utils/diff-rendering";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
@@ -164,7 +175,7 @@ function DiffGutterCell({
|
||||
type === "remove" && styles.removeLineNumberText,
|
||||
]}
|
||||
>
|
||||
{lineNumber != null ? String(lineNumber) : ""}
|
||||
{formatDiffGutterText(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -177,10 +188,12 @@ function DiffTextLine({
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
|
||||
{line.tokens && line.type !== "header" ? (
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -192,7 +205,7 @@ function DiffTextLine({
|
||||
line.type === "context" && styles.contextLineText,
|
||||
]}
|
||||
>
|
||||
{line.content || " "}
|
||||
{formatDiffContentText(line.content)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -206,10 +219,12 @@ function SplitTextLine({
|
||||
line: SplitDiffDisplayLine | null;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
|
||||
{line?.tokens ? (
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -221,7 +236,7 @@ function SplitTextLine({
|
||||
!line && styles.emptySplitCellText,
|
||||
]}
|
||||
>
|
||||
{line?.content ?? ""}
|
||||
{formatDiffContentText(line?.content)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -239,6 +254,8 @@ function DiffLineView({
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -254,11 +271,11 @@ function DiffLineView({
|
||||
line.type === "remove" && styles.removeLineNumberText,
|
||||
]}
|
||||
>
|
||||
{lineNumber != null ? String(lineNumber) : ""}
|
||||
{formatDiffGutterText(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
{line.tokens && line.type !== "header" ? (
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -270,7 +287,7 @@ function DiffLineView({
|
||||
line.type === "context" && styles.contextLineText,
|
||||
]}
|
||||
>
|
||||
{line.content || " "}
|
||||
{formatDiffContentText(line.content)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -286,6 +303,8 @@ function SplitDiffLine({
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -301,11 +320,11 @@ function SplitDiffLine({
|
||||
line?.type === "remove" && styles.removeLineNumberText,
|
||||
]}
|
||||
>
|
||||
{line?.lineNumber != null ? String(line.lineNumber) : ""}
|
||||
{formatDiffGutterText(line?.lineNumber ?? null)}
|
||||
</Text>
|
||||
</View>
|
||||
{line?.tokens ? (
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -317,7 +336,7 @@ function SplitDiffLine({
|
||||
!line && styles.emptySplitCellText,
|
||||
]}
|
||||
>
|
||||
{line?.content ?? ""}
|
||||
{formatDiffContentText(line?.content)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -534,26 +553,7 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = [];
|
||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
||||
let lineNumber: number | null = null;
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNo++;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNo++;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNo++;
|
||||
newLineNo++;
|
||||
}
|
||||
computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` });
|
||||
}
|
||||
}
|
||||
const computedLines = buildUnifiedDiffLines(file);
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
@@ -607,7 +607,7 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
@@ -1724,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
newBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
deletedBadge: {
|
||||
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
||||
@@ -1736,17 +1736,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
deletedBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
diffContent: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
@@ -1824,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
userSelect: "none",
|
||||
},
|
||||
addLineNumberText: {
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
removeLineNumberText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
diffLineText: {
|
||||
flex: 1,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
@@ -44,7 +44,7 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
isCompactFormFactor,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -26,7 +26,7 @@ interface ScreenHeaderProps {
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const padding = useWindowControlsPadding("header");
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
|
||||
@@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
isCompactFormFactor,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
buildHostSessionsRoute,
|
||||
@@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
newAgentButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
newAgentButtonActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
@@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
hostTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
hostStatusDot: {
|
||||
width: 8,
|
||||
|
||||
@@ -87,6 +87,9 @@ export interface MessageInputProps {
|
||||
voiceAgentId?: string;
|
||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||
isAgentRunning?: boolean;
|
||||
/** Controls what the default send action (Enter, send button, dictation) does
|
||||
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
|
||||
defaultSendBehavior?: "interrupt" | "queue";
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Optional handler used when submit button is in loading state. */
|
||||
@@ -208,6 +211,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
isAgentRunning = false,
|
||||
defaultSendBehavior = "interrupt",
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
@@ -352,11 +356,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
if (shouldAutoSend) {
|
||||
const imageAttachments = images.length > 0 ? images : undefined;
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
});
|
||||
// Respect send behavior setting: when "queue", dictation queues too.
|
||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||
onQueue({ text: nextValue, images: imageAttachments });
|
||||
onChangeText("");
|
||||
} else {
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onChangeText(nextValue);
|
||||
}
|
||||
@@ -367,7 +377,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, images, isAgentRunning],
|
||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback(
|
||||
@@ -578,6 +588,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
||||
|
||||
// Default send action: respects the sendBehavior setting.
|
||||
// When "interrupt" (default), primary action sends immediately (interrupts).
|
||||
// When "queue", primary action queues when agent is running.
|
||||
const handleDefaultSendAction = useCallback(() => {
|
||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||
handleQueueMessage();
|
||||
} else {
|
||||
handleSendMessage();
|
||||
}
|
||||
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
|
||||
|
||||
// Alternate send action: always the opposite of the default.
|
||||
const handleAlternateSendAction = useCallback(() => {
|
||||
if (defaultSendBehavior === "queue") {
|
||||
handleSendMessage(); // interrupt
|
||||
} else if (onQueue) {
|
||||
handleQueueMessage(); // queue
|
||||
}
|
||||
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
|
||||
|
||||
// Web input height measurement
|
||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||
@@ -872,18 +902,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||
if (shiftKey) return;
|
||||
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
|
||||
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||
event.preventDefault();
|
||||
handleQueueMessage();
|
||||
handleAlternateSendAction();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: send (interrupts agent if running)
|
||||
// Enter: default send action (interrupt or queue, based on setting)
|
||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||
event.preventDefault();
|
||||
handleSendMessage();
|
||||
handleDefaultSendAction();
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
@@ -892,11 +922,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
||||
const submitAccessibilityLabel = canPressLoadingButton
|
||||
? "Interrupt agent"
|
||||
: isAgentRunning
|
||||
? "Send and interrupt"
|
||||
: "Send message";
|
||||
: defaultActionQueues
|
||||
? "Queue message"
|
||||
: isAgentRunning
|
||||
? "Send and interrupt"
|
||||
: "Send message";
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
@@ -1071,10 +1104,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{hasSendableContent && isAgentRunning && onQueue && (
|
||||
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleQueueMessage}
|
||||
onPress={handleAlternateSendAction}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
@@ -1099,7 +1132,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
|
||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
@@ -1113,7 +1146,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Send</Text>
|
||||
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
Scissors,
|
||||
MicVocal,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
@@ -1822,7 +1823,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||
if (detail) {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
field: {
|
||||
marginTop: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
export interface NameHostModalProps {
|
||||
visible: boolean;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
onSkip: () => void;
|
||||
onSave: (label: string) => void;
|
||||
}
|
||||
|
||||
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const hasEditedRef = useRef(false);
|
||||
|
||||
const suggested = (hostname?.trim() || serverId).trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setLabel(suggested);
|
||||
hasEditedRef.current = false;
|
||||
}, [suggested, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (hasEditedRef.current) return;
|
||||
if (!hostname) return;
|
||||
const trimmed = label.trim();
|
||||
if (trimmed.length === 0 || trimmed === serverId) {
|
||||
setLabel(hostname.trim());
|
||||
}
|
||||
}, [hostname, label, serverId, visible]);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
hasEditedRef.current = true;
|
||||
setLabel(value);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
onSkip();
|
||||
return;
|
||||
}
|
||||
onSave(trimmed);
|
||||
}, [label, onSave, onSkip]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Name this host"
|
||||
visible={visible}
|
||||
onClose={onSkip}
|
||||
testID="name-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<AdaptiveTextInput
|
||||
value={label}
|
||||
onChangeText={handleChange}
|
||||
placeholder={suggested}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
|
||||
Skip
|
||||
</Button>
|
||||
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
@@ -66,7 +67,7 @@ export function PairLinkModal({
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [offerUrl, setOfferUrl] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -146,7 +147,7 @@ export function PairLinkModal({
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
@@ -63,7 +64,7 @@ const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = parseQuestions(permission.request.input);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
|
||||
@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -713,7 +713,7 @@ function ProjectHeaderRow({
|
||||
}: ProjectHeaderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint = isCompactFormFactor();
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
|
||||
@@ -1606,7 +1606,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isNative = Platform.OS !== "web";
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
@@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
projectRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
projectRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
projectActionButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
@@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
...theme.shadow.md,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
@@ -2241,12 +2241,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -92,7 +92,7 @@ export function TerminalPane({
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
||||
import { createPortal } from "react-dom";
|
||||
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ export function ToastViewport({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -181,7 +183,6 @@ export function ToastViewport({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
filterAndRankComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
} from "./combobox-options";
|
||||
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankComboboxOptions", () => {
|
||||
const options = [
|
||||
{ id: "feat/login", label: "feat/login" },
|
||||
{ id: "main", label: "main" },
|
||||
{ id: "feat/main-nav", label: "feat/main-nav" },
|
||||
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
||||
];
|
||||
|
||||
it("returns all options when search is empty", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
||||
});
|
||||
|
||||
it("filters by label substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "login");
|
||||
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
||||
});
|
||||
|
||||
it("filters by id substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "fix/");
|
||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||
});
|
||||
|
||||
it("filters by description substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "logout bug");
|
||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||
});
|
||||
|
||||
it("ranks prefix matches above substring matches", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "main");
|
||||
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const items = [{ id: "Alpha", label: "Alpha" }];
|
||||
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
const visible = [
|
||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||
|
||||
@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAndRankComboboxOptions(
|
||||
options: ComboboxOptionModel[],
|
||||
search: string,
|
||||
): ComboboxOptionModel[] {
|
||||
if (!search) return options;
|
||||
return options
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(search) ||
|
||||
opt.id.toLowerCase().includes(search) ||
|
||||
opt.description?.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aPrefix =
|
||||
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||
const bPrefix =
|
||||
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisibleComboboxOptions(
|
||||
input: BuildVisibleComboboxOptionsInput,
|
||||
): ComboboxOptionModel[] {
|
||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||
const filteredOptions = normalizedSearch
|
||||
? input.options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.description?.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
: input.options;
|
||||
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
||||
|
||||
const sanitizedSearchValue = input.searchQuery.trim();
|
||||
const showCustomOption = shouldShowCustomComboboxOption({
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
@@ -261,7 +262,7 @@ export function Combobox({
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||
@@ -659,13 +660,7 @@ export function Combobox({
|
||||
</>
|
||||
);
|
||||
|
||||
const defaultContent = (
|
||||
<>
|
||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||
</>
|
||||
);
|
||||
const defaultContent = optionsList;
|
||||
|
||||
const content = children ?? defaultContent;
|
||||
|
||||
@@ -690,6 +685,7 @@ export function Combobox({
|
||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||
</View>
|
||||
{stickyHeader}
|
||||
{!children && searchable ? searchInput : null}
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.comboboxScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -745,6 +741,7 @@ export function Combobox({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "above-search" ? (
|
||||
<ScrollView
|
||||
ref={desktopOptionsScrollRef}
|
||||
@@ -759,9 +756,7 @@ export function Combobox({
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "below-search" ? (
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -770,7 +765,7 @@ export function Combobox({
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
@@ -783,15 +778,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
marginTop: theme.spacing[1],
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -347,7 +348,7 @@ export function ContextMenuContent({
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -551,9 +551,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.border,
|
||||
...theme.shadow.sm,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
...theme.shadow.md,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -8,14 +8,11 @@ import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { NameHostModal } from "./name-host-modal";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
@@ -237,41 +234,19 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { renameHost } = useHostMutations();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyOnlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
}, [anyOnlineServerId, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -396,13 +371,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
@@ -410,38 +380,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingNameHost && pendingRedirectServerId ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={pendingNameHost.serverId}
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
@@ -62,16 +62,13 @@ export function getIsElectronRuntime(): boolean {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isCompactFormFactor(): boolean {
|
||||
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
}
|
||||
|
||||
export function isDesktopFormFactor(): boolean {
|
||||
return !isCompactFormFactor();
|
||||
}
|
||||
|
||||
export function isTouchDesktopFormFactor(): boolean {
|
||||
return Platform.OS !== "web" && isDesktopFormFactor();
|
||||
/**
|
||||
* Reactive hook — re-renders the component when the breakpoint changes.
|
||||
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
|
||||
*/
|
||||
export function useIsCompactFormFactor(): boolean {
|
||||
const { rt } = useUnistyles();
|
||||
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
|
||||
}
|
||||
|
||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getRightSidebarAnimationTargets,
|
||||
@@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
||||
|
||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
@@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
||||
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import {
|
||||
@@ -21,15 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null;
|
||||
@@ -40,44 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
|
||||
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
|
||||
const loadDaemonData = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setDaemonStatus(status);
|
||||
setDaemonLogs(logs);
|
||||
setDaemonVersion(status.version);
|
||||
setStatusError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusError(message);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) {
|
||||
return undefined;
|
||||
}
|
||||
void loadDaemonData();
|
||||
return undefined;
|
||||
}, [loadDaemonData, showSection]),
|
||||
);
|
||||
const daemonStatus = data?.status ?? null;
|
||||
const daemonLogs = data?.logs ?? null;
|
||||
const daemonVersion = daemonStatus?.version ?? null;
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
@@ -116,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
||||
|
||||
void action()
|
||||
.then((status) => {
|
||||
setDaemonStatus(status);
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setStatusMessage(
|
||||
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
||||
);
|
||||
return loadDaemonData();
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to change desktop daemon state", error);
|
||||
@@ -136,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
@@ -182,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
: Promise.resolve(daemonStatus ?? null);
|
||||
|
||||
void stopPromise
|
||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||
.then(() => loadDaemonData())
|
||||
.then((newStatus) => {
|
||||
if (newStatus) {
|
||||
setStatus(newStatus);
|
||||
}
|
||||
return updateSettings({ manageBuiltInDaemon: false });
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setStatusMessage("Built-in daemon paused and stopped.");
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -202,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
}, [
|
||||
daemonStatus,
|
||||
isUpdatingDaemonManagement,
|
||||
loadDaemonData,
|
||||
refetch,
|
||||
setStatus,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
]);
|
||||
@@ -277,126 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
Advanced settings
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.loadingCard]}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
) : (
|
||||
<>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Only the built-in desktop daemon is shown here.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? daemonStatus?.status === "running"
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
{daemonLogs?.logPath ?? "Log path unavailable."}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogPath}
|
||||
>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? daemonStatus?.status === "running"
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogPath}
|
||||
>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
App and daemon versions don't match. Update both to the same version for the best
|
||||
experience.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
App and daemon versions don't match. Update both to the same version for the best
|
||||
experience.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
@@ -447,6 +435,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
loadingCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
statusValueGroup: {
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
|
||||
53
packages/app/src/desktop/hooks/use-daemon-status.ts
Normal file
53
packages/app/src/desktop/hooks/use-daemon-status.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
shouldUseDesktopDaemon,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
|
||||
|
||||
interface DaemonStatusData {
|
||||
status: DesktopDaemonStatus;
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const query = useQuery<DaemonStatusData>({
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
return { status, logs };
|
||||
},
|
||||
});
|
||||
|
||||
const setStatus = useCallback(
|
||||
(status: DesktopDaemonStatus) => {
|
||||
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
|
||||
prev ? { ...prev, status } : undefined,
|
||||
);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
data: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error instanceof Error ? query.error.message : null,
|
||||
setStatus,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
body: string | null;
|
||||
@@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
|
||||
|
||||
return {
|
||||
hasUpdate: result.hasUpdate === true,
|
||||
readyToInstall: result.readyToInstall === true,
|
||||
currentVersion: toStringOrNull(result.currentVersion),
|
||||
latestVersion: toStringOrNull(result.latestVersion),
|
||||
body: toStringOrNull(result.body),
|
||||
|
||||
@@ -55,7 +55,7 @@ export function UpdateBanner() {
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (isInstalled) return "Restart to use the new version.";
|
||||
if (isInstalling) return "Downloading and installing...";
|
||||
if (isInstalling) return "Installing and restarting...";
|
||||
if (isError) return errorMessage ?? "Something went wrong.";
|
||||
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
checkDesktopAppUpdate,
|
||||
formatVersionWithPrefix,
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "pending"
|
||||
| "up-to-date"
|
||||
| "available"
|
||||
| "installing"
|
||||
| "installed"
|
||||
| "error";
|
||||
|
||||
const PENDING_RECHECK_MS = 10_000;
|
||||
|
||||
export interface UseDesktopAppUpdaterReturn {
|
||||
isDesktopApp: boolean;
|
||||
status: DesktopAppUpdateStatus;
|
||||
@@ -56,11 +59,15 @@ function formatStatusText(input: {
|
||||
return "App is up to date.";
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return "We'll let you know when the update is ready.";
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
|
||||
return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
|
||||
}
|
||||
return "An app update is available.";
|
||||
return "An app update is ready to install.";
|
||||
}
|
||||
|
||||
if (status === "installed") {
|
||||
@@ -106,9 +113,12 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setInstallMessage(null);
|
||||
setLastCheckedAt(Date.now());
|
||||
|
||||
if (result.hasUpdate) {
|
||||
if (result.readyToInstall) {
|
||||
setAvailableUpdate(result);
|
||||
setStatus("available");
|
||||
} else if (result.hasUpdate) {
|
||||
setAvailableUpdate(null);
|
||||
setStatus("pending");
|
||||
} else {
|
||||
setAvailableUpdate(null);
|
||||
setStatus("up-to-date");
|
||||
@@ -133,6 +143,20 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
[isDesktopApp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp || status !== "pending") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void checkForUpdates({ silent: true });
|
||||
}, PENDING_RECHECK_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkForUpdates, isDesktopApp, status]);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
|
||||
53
packages/app/src/hooks/feature-preferences.test.ts
Normal file
53
packages/app/src/hooks/feature-preferences.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveFeatureValues } from "./feature-preferences";
|
||||
|
||||
describe("feature-preferences", () => {
|
||||
const features = [
|
||||
{
|
||||
type: "toggle" as const,
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: "toggle" as const,
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
|
||||
it("restores persisted values for available features", () => {
|
||||
expect(
|
||||
resolveFeatureValues({
|
||||
features,
|
||||
persistedFeatureValues: {
|
||||
fast_mode: true,
|
||||
unknown_feature: true,
|
||||
},
|
||||
localFeatureValues: {},
|
||||
}),
|
||||
).toEqual({
|
||||
fast_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers local values over persisted values", () => {
|
||||
expect(
|
||||
resolveFeatureValues({
|
||||
features,
|
||||
persistedFeatureValues: {
|
||||
fast_mode: true,
|
||||
plan_mode: false,
|
||||
},
|
||||
localFeatureValues: {
|
||||
fast_mode: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
fast_mode: false,
|
||||
plan_mode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
60
packages/app/src/hooks/feature-preferences.ts
Normal file
60
packages/app/src/hooks/feature-preferences.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { AgentFeature } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export function pruneFeatureValues(
|
||||
featureValues: Record<string, unknown>,
|
||||
features: AgentFeature[],
|
||||
): Record<string, unknown> {
|
||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
||||
if (!allowedFeatureIds.has(featureId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[featureId] = value;
|
||||
}
|
||||
|
||||
return changed ? next : featureValues;
|
||||
}
|
||||
|
||||
export function applyFeatureValues(
|
||||
features: AgentFeature[],
|
||||
featureValues: Record<string, unknown>,
|
||||
): AgentFeature[] {
|
||||
if (Object.keys(featureValues).length === 0) {
|
||||
return features;
|
||||
}
|
||||
|
||||
return features.map((feature) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
return {
|
||||
...feature,
|
||||
value: featureValues[feature.id],
|
||||
} as AgentFeature;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFeatureValues(args: {
|
||||
features: AgentFeature[];
|
||||
persistedFeatureValues: Record<string, unknown>;
|
||||
localFeatureValues: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const feature of args.features) {
|
||||
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
|
||||
next[feature.id] = args.localFeatureValues[feature.id];
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
|
||||
next[feature.id] = args.persistedFeatureValues[feature.id];
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -93,6 +93,7 @@ type UseAgentFormStateResult = {
|
||||
isModelLoading: boolean;
|
||||
modelError: string | null;
|
||||
refreshProviderModels: () => void;
|
||||
invalidateProviderModels: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
@@ -375,6 +376,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
isFetching: snapshotIsFetching,
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
invalidate: invalidateSnapshot,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
@@ -648,33 +650,36 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const invalidateProviderModels = useCallback(() => {
|
||||
invalidateSnapshot();
|
||||
}, [invalidateSnapshot]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const modelId = resolvedModel?.id ?? formState.model;
|
||||
const nextPreferences = mergeProviderPreferences({
|
||||
preferences: preferences ?? {},
|
||||
provider: formState.provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
...(modelId && formState.thinkingOptionId
|
||||
? {
|
||||
thinkingByModel: {
|
||||
[modelId]: formState.thinkingOptionId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies Partial<ProviderPreferences>,
|
||||
});
|
||||
|
||||
await updatePreferences(nextPreferences);
|
||||
await updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: formState.provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
...(modelId && formState.thinkingOptionId
|
||||
? {
|
||||
thinkingByModel: {
|
||||
[modelId]: formState.thinkingOptionId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies Partial<ProviderPreferences>,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
availableModels,
|
||||
formState.model,
|
||||
formState.modeId,
|
||||
formState.provider,
|
||||
formState.thinkingOptionId,
|
||||
preferences?.providerPreferences,
|
||||
updatePreferences,
|
||||
]);
|
||||
|
||||
@@ -715,6 +720,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
invalidateProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
@@ -746,6 +752,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
invalidateProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
|
||||
164
packages/app/src/hooks/use-branch-switcher.ts
Normal file
164
packages/app/src/hooks/use-branch-switcher.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
client: DaemonClient | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
currentBranchName: string | null;
|
||||
isGitCheckout: boolean;
|
||||
isConnected: boolean;
|
||||
toast: ToastApi;
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
interface UseBranchSwitcherResult {
|
||||
branchOptions: ComboboxOption[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
handleBranchSelect: (branchId: string) => void;
|
||||
invalidateStashAndCheckout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
toast,
|
||||
queryClient,
|
||||
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getBranchSuggestions({
|
||||
cwd: normalizedWorkspaceId,
|
||||
limit: 200,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.branches ?? [];
|
||||
},
|
||||
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const branchOptions = useMemo<ComboboxOption[]>(() => {
|
||||
const branches = branchSuggestionsQuery.data ?? [];
|
||||
return branches.map((name) => ({ id: name, label: name }));
|
||||
}, [branchSuggestionsQuery.data]);
|
||||
|
||||
const stashListQueryKey = useMemo(
|
||||
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
|
||||
const invalidateStashAndCheckout = useCallback(async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
||||
}),
|
||||
]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
|
||||
|
||||
const stashAndSwitch = useCallback(
|
||||
async (branchId: string) => {
|
||||
if (!client) return;
|
||||
const shouldStash = await confirmDialog({
|
||||
title: "Uncommitted changes",
|
||||
message:
|
||||
"You have uncommitted changes. Stash them before switching branches?",
|
||||
confirmLabel: "Stash & Switch",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!shouldStash) return;
|
||||
|
||||
try {
|
||||
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
|
||||
branch: currentBranchName ?? undefined,
|
||||
});
|
||||
if (stashPayload.error) {
|
||||
toast.error(stashPayload.error.message);
|
||||
return;
|
||||
}
|
||||
await invalidateStashAndCheckout();
|
||||
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
if (switchPayload.error) {
|
||||
toast.error(switchPayload.error.message);
|
||||
return;
|
||||
}
|
||||
await invalidateStashAndCheckout();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
|
||||
}
|
||||
},
|
||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
|
||||
);
|
||||
|
||||
const handleBranchSelect = useCallback(
|
||||
(branchId: string) => {
|
||||
if (branchId === currentBranchName) return;
|
||||
|
||||
void (async () => {
|
||||
if (!client) return;
|
||||
try {
|
||||
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||
if (payload.error) {
|
||||
// If the error is about uncommitted changes, offer the stash dialog
|
||||
if (payload.error.message.toLowerCase().includes("uncommitted")) {
|
||||
await stashAndSwitch(branchId);
|
||||
return;
|
||||
}
|
||||
toast.error(payload.error.message);
|
||||
return;
|
||||
}
|
||||
// Success — refresh and check for stashes on the target branch
|
||||
await invalidateStashAndCheckout();
|
||||
try {
|
||||
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
|
||||
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
|
||||
if (targetStash) {
|
||||
const shouldRestore = await confirmDialog({
|
||||
title: "Restore stashed changes?",
|
||||
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
|
||||
confirmLabel: "Restore",
|
||||
cancelLabel: "Later",
|
||||
});
|
||||
if (shouldRestore) {
|
||||
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
|
||||
if (popPayload.error) {
|
||||
toast.error(popPayload.error.message);
|
||||
} else {
|
||||
toast.show("Stashed changes restored");
|
||||
}
|
||||
await invalidateStashAndCheckout();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — user can still restore on next branch switch
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
|
||||
}
|
||||
})();
|
||||
},
|
||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
|
||||
);
|
||||
|
||||
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
@@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
@@ -32,7 +32,7 @@ function fetchCheckoutStatus(
|
||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
function pruneFeatureValues(
|
||||
featureValues: Record<string, unknown>,
|
||||
features: AgentFeature[],
|
||||
): Record<string, unknown> {
|
||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
||||
if (!allowedFeatureIds.has(featureId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[featureId] = value;
|
||||
}
|
||||
|
||||
return changed ? next : featureValues;
|
||||
}
|
||||
|
||||
function applyFeatureValues(
|
||||
features: AgentFeature[],
|
||||
featureValues: Record<string, unknown>,
|
||||
): AgentFeature[] {
|
||||
if (Object.keys(featureValues).length === 0) {
|
||||
return features;
|
||||
}
|
||||
|
||||
return features.map((feature) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
return {
|
||||
...feature,
|
||||
value: featureValues[feature.id],
|
||||
} as AgentFeature;
|
||||
});
|
||||
}
|
||||
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
||||
import {
|
||||
applyFeatureValues,
|
||||
pruneFeatureValues,
|
||||
resolveFeatureValues,
|
||||
} from "./feature-preferences";
|
||||
|
||||
type DraftFeatureConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
@@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: {
|
||||
thinkingOptionId: string | null | undefined;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
||||
[preferences.providerPreferences, provider],
|
||||
);
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
if (!normalizedCwd) {
|
||||
@@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: {
|
||||
return payload.features ?? [];
|
||||
},
|
||||
});
|
||||
const availableFeatures = featuresQuery.data ?? [];
|
||||
const featureValues = useMemo(
|
||||
() =>
|
||||
resolveFeatureValues({
|
||||
features: availableFeatures,
|
||||
persistedFeatureValues,
|
||||
localFeatureValues,
|
||||
}),
|
||||
[availableFeatures, localFeatureValues, persistedFeatureValues],
|
||||
);
|
||||
|
||||
const features = useMemo(() => {
|
||||
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
|
||||
}, [featureValues, featuresQuery.data]);
|
||||
return applyFeatureValues(availableFeatures, featureValues);
|
||||
}, [availableFeatures, featureValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = pruneFeatureValues(featureValues, features);
|
||||
if (next !== featureValues) {
|
||||
setFeatureValues(next);
|
||||
setLocalFeatureValues({});
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
|
||||
if (next !== localFeatureValues) {
|
||||
setLocalFeatureValues(next);
|
||||
}
|
||||
}, [featureValues, features]);
|
||||
}, [availableFeatures, localFeatureValues]);
|
||||
|
||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
|
||||
setFeatureValues((current) => {
|
||||
setLocalFeatureValues((current) => {
|
||||
if (Object.is(current[featureId], value)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, [featureId]: value };
|
||||
});
|
||||
}, []);
|
||||
void updatePreferences(
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
updates: {
|
||||
featureValues: {
|
||||
[featureId]: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
|
||||
});
|
||||
}, [provider, updatePreferences]);
|
||||
|
||||
return {
|
||||
features,
|
||||
|
||||
@@ -59,6 +59,41 @@ describe("mergeProviderPreferences", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges feature values without dropping existing entries", () => {
|
||||
expect(
|
||||
mergeProviderPreferences({
|
||||
preferences: {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
featureValues: {
|
||||
plan_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
plan_mode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("favorite model preferences", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingByModel: z.record(z.string()).optional(),
|
||||
featureValues: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
@@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
||||
export interface UseFormPreferencesReturn {
|
||||
preferences: FormPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||
updatePreferences: (
|
||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function mergeProviderPreferences(args: {
|
||||
@@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: {
|
||||
...existing.thinkingByModel,
|
||||
...updates.thinkingByModel,
|
||||
};
|
||||
const nextFeatureValues =
|
||||
updates.featureValues === undefined
|
||||
? existing.featureValues
|
||||
: {
|
||||
...existing.featureValues,
|
||||
...updates.featureValues,
|
||||
};
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
@@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: {
|
||||
...existing,
|
||||
...updates,
|
||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<FormPreferences>) => {
|
||||
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_FORM_PREFERENCES;
|
||||
const next = { ...prev, ...updates };
|
||||
const next =
|
||||
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
|
||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ interface UseProvidersSnapshotResult {
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: () => void;
|
||||
invalidate: () => void;
|
||||
}
|
||||
|
||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||
@@ -66,6 +67,10 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
||||
void client.refreshProvidersSnapshot();
|
||||
}, [client]);
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
isLoading: snapshotQuery.isLoading,
|
||||
@@ -73,6 +78,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||
supportsSnapshot,
|
||||
refresh,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,22 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "dark" | "light" | "auto";
|
||||
theme: ThemeName | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
sendBehavior: SendBehavior;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
@@ -74,6 +82,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
||||
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
||||
}
|
||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -189,6 +190,17 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
||||
return {
|
||||
v: 2,
|
||||
serverId: input?.serverId ?? "srv_offer",
|
||||
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
|
||||
relay: {
|
||||
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[],
|
||||
@@ -1295,4 +1307,53 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("mbp");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await store.upsertRelayConnection({
|
||||
serverId: "srv_offer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
label: "Custom name",
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("Custom name");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1287,15 +1287,22 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
|
||||
async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
): Promise<HostProfile> {
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
|
||||
async upsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
): Promise<HostProfile> {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
@@ -1307,7 +1314,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return this.upsertConnectionFromOffer(offer);
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
}
|
||||
|
||||
async addConnectionFromListenAndWaitForOnline(input: {
|
||||
@@ -1956,8 +1963,11 @@ export interface HostMutations {
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
) => Promise<HostProfile>;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
@@ -1969,8 +1979,8 @@ export function useHostMutations(): HostMutations {
|
||||
() => ({
|
||||
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
|
||||
upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
removeHost: (serverId) => store.removeHost(serverId),
|
||||
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
@@ -226,6 +226,7 @@ function DraftAgentScreenContent({
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
invalidateProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
@@ -235,7 +236,7 @@ function DraftAgentScreenContent({
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
@@ -1268,6 +1269,7 @@ function DraftAgentScreenContent({
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onModelSelectorOpen: invalidateProviderModels,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -17,7 +17,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompactLayout) {
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
Settings,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Server,
|
||||
Palette,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Blocks,
|
||||
Smartphone,
|
||||
} from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
@@ -42,13 +43,13 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
@@ -65,7 +66,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
@@ -77,7 +78,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "general"
|
||||
| "shortcuts"
|
||||
| "integrations"
|
||||
| "providers"
|
||||
@@ -96,21 +97,21 @@ interface SettingsSectionDef {
|
||||
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
|
||||
const sections: SettingsSectionDef[] = [
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
];
|
||||
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
{ id: "providers", label: "Providers", icon: Blocks },
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
{ id: "providers", label: "Providers", icon: Blocks },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
);
|
||||
@@ -204,10 +205,6 @@ interface HostsSectionProps {
|
||||
goBackToAddConnectionMethods: () => void;
|
||||
setIsDirectHostVisible: (visible: boolean) => void;
|
||||
setIsPasteLinkVisible: (visible: boolean) => void;
|
||||
pendingNameHost: { serverId: string; hostname: string | null } | null;
|
||||
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
|
||||
pendingNameHostname: string | null;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
pendingRemoveHost: HostProfile | null;
|
||||
setPendingRemoveHost: (host: HostProfile | null) => void;
|
||||
isRemovingHost: boolean;
|
||||
@@ -290,38 +287,14 @@ function HostsSection(props: HostsSectionProps) {
|
||||
visible={props.isDirectHostVisible}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
if (isNewHost) {
|
||||
props.setPendingNameHost({ serverId, hostname });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={props.isPasteLinkVisible}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
if (isNewHost) {
|
||||
props.setPendingNameHost({ serverId, hostname });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{props.pendingNameHost ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={props.pendingNameHost.serverId}
|
||||
hostname={props.pendingNameHostname}
|
||||
onSkip={() => props.setPendingNameHost(null)}
|
||||
onSave={(label) => {
|
||||
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
|
||||
props.setPendingNameHost(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.pendingRemoveHost ? (
|
||||
<AdaptiveModalSheet
|
||||
title="Remove host"
|
||||
@@ -386,41 +359,119 @@ function HostsSection(props: HostsSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
}
|
||||
|
||||
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
|
||||
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun size={size} color={color} />;
|
||||
case "dark":
|
||||
return <Moon size={size} color={color} />;
|
||||
case "auto":
|
||||
return <Monitor size={size} color={color} />;
|
||||
default:
|
||||
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ThemeSwatch({ color, size }: { color: string; size: number }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255,255,255,0.15)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
zinc: "Zinc",
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
auto: "System",
|
||||
};
|
||||
|
||||
function GeneralSection({
|
||||
settings,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconSize = theme.iconSize.md;
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>General</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={({ pressed }) => [
|
||||
styles.themeTrigger,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
>
|
||||
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{THEME_LABELS[settings.theme]}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{(["light", "dark", "auto"] as const).map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t}
|
||||
selected={settings.theme === t}
|
||||
onSelect={() => handleThemeChange(t)}
|
||||
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
|
||||
>
|
||||
{THEME_LABELS[t]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t}
|
||||
selected={settings.theme === t}
|
||||
onSelect={() => handleThemeChange(t)}
|
||||
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
|
||||
>
|
||||
{THEME_LABELS[t]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Default send</Text>
|
||||
<Text style={styles.audioRowSubtitle}>
|
||||
What happens when you press Enter while the agent is running
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
hideLabels={Platform.OS !== "web"}
|
||||
value={settings.theme}
|
||||
onValueChange={handleThemeChange}
|
||||
value={settings.sendBehavior}
|
||||
onValueChange={handleSendBehaviorChange}
|
||||
options={[
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: ({ color, size }) => <Sun size={size} color={color} />,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: ({ color, size }) => <Moon size={size} color={color} />,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "System",
|
||||
icon: ({ color, size }) => <Monitor size={size} color={color} />,
|
||||
},
|
||||
{ value: "interrupt", label: "Interrupt" },
|
||||
{ value: "queue", label: "Queue" },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
@@ -614,7 +665,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
interface SettingsSectionContentProps {
|
||||
sectionId: SettingsSectionId;
|
||||
hostsProps: HostsSectionProps;
|
||||
appearanceProps: AppearanceSectionProps;
|
||||
generalProps: GeneralSectionProps;
|
||||
providersProps: ProvidersSectionProps;
|
||||
diagnosticsProps: DiagnosticsSectionProps;
|
||||
aboutProps: AboutSectionProps;
|
||||
@@ -626,7 +677,7 @@ interface SettingsSectionContentProps {
|
||||
function SettingsSectionContent({
|
||||
sectionId,
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
generalProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
@@ -637,8 +688,8 @@ function SettingsSectionContent({
|
||||
switch (sectionId) {
|
||||
case "hosts":
|
||||
return <HostsSection {...hostsProps} />;
|
||||
case "appearance":
|
||||
return <AppearanceSection {...appearanceProps} />;
|
||||
case "general":
|
||||
return <GeneralSection {...generalProps} />;
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsSection />;
|
||||
case "providers":
|
||||
@@ -815,7 +866,7 @@ function DesktopAppUpdateRow() {
|
||||
<Text style={styles.aboutHintText}>{statusText}</Text>
|
||||
{availableUpdate?.latestVersion ? (
|
||||
<Text style={styles.aboutHintText}>
|
||||
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||
</Text>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
||||
@@ -863,10 +914,6 @@ export default function SettingsScreen() {
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
@@ -884,19 +931,6 @@ export default function SettingsScreen() {
|
||||
const editingDaemonLive = editingServerId
|
||||
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
|
||||
: null;
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1003,6 +1037,13 @@ export default function SettingsScreen() {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
void updateSettings({ sendBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -1032,7 +1073,7 @@ export default function SettingsScreen() {
|
||||
}
|
||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const sections = getSettingsSections({ isDesktopApp });
|
||||
|
||||
const hostsProps: HostsSectionProps = {
|
||||
@@ -1049,10 +1090,6 @@ export default function SettingsScreen() {
|
||||
goBackToAddConnectionMethods,
|
||||
setIsDirectHostVisible,
|
||||
setIsPasteLinkVisible,
|
||||
pendingNameHost,
|
||||
setPendingNameHost,
|
||||
pendingNameHostname,
|
||||
renameHost,
|
||||
pendingRemoveHost,
|
||||
setPendingRemoveHost,
|
||||
isRemovingHost,
|
||||
@@ -1069,9 +1106,10 @@ export default function SettingsScreen() {
|
||||
isMountedRef,
|
||||
};
|
||||
|
||||
const appearanceProps: AppearanceSectionProps = {
|
||||
const generalProps: GeneralSectionProps = {
|
||||
settings,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
};
|
||||
|
||||
const diagnosticsProps: DiagnosticsSectionProps = {
|
||||
@@ -1092,7 +1130,7 @@ export default function SettingsScreen() {
|
||||
|
||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
generalProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
@@ -1827,6 +1865,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
themeTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
themeTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
@@ -1901,6 +1953,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
audioRowSubtitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
providerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -38,6 +38,28 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
errorScreen: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
errorScrollView: {
|
||||
flex: 1,
|
||||
...(Platform.OS === "web"
|
||||
? {
|
||||
overflowX: "auto",
|
||||
overflowY: "auto",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
errorScrollContent: {
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingHorizontal: theme.spacing[8],
|
||||
paddingVertical: theme.spacing[8],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
centeredContent: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -255,67 +277,73 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, styles.containerError]}>
|
||||
<View style={styles.errorScreen}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||
<ScrollView
|
||||
style={styles.errorScrollView}
|
||||
contentContainerStyle={styles.errorScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.errorDescription}>
|
||||
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>
|
||||
{bootstrapState.error}
|
||||
</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={styles.logsScroll}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Text selectable style={styles.logsText}>
|
||||
{logsText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogs}
|
||||
>
|
||||
Copy logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||
>
|
||||
Open GitHub issue
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
||||
onPress={bootstrapState.retry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.errorDescription}>
|
||||
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>
|
||||
{bootstrapState.error}
|
||||
</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={styles.logsScroll}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Text selectable style={styles.logsText}>
|
||||
{logsText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogs}
|
||||
>
|
||||
Copy logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||
>
|
||||
Open GitHub issue
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
||||
onPress={bootstrapState.retry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function WorkspaceDraftAgentTab({
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
setProviderAndModelFromUser,
|
||||
invalidateProviderModels,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: serverId,
|
||||
@@ -373,6 +374,7 @@ export function WorkspaceDraftAgentTab({
|
||||
features: draftFeatures,
|
||||
onSetFeature: handleSetFeatureWithFocus,
|
||||
onDropdownClose: () => focusInputRef.current?.(),
|
||||
onModelSelectorOpen: invalidateProviderModels,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ import invariant from "tiny-invariant";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -116,7 +118,7 @@ import {
|
||||
closeBulkWorkspaceTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
@@ -591,7 +593,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const insets = useSafeAreaInsets();
|
||||
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
||||
const toast = useToast();
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||
@@ -759,6 +761,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||
: null;
|
||||
|
||||
const {
|
||||
branchOptions,
|
||||
isOpen: isBranchSwitcherOpen,
|
||||
setIsOpen: setIsBranchSwitcherOpen,
|
||||
handleBranchSelect,
|
||||
invalidateStashAndCheckout,
|
||||
} = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
toast,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
@@ -1948,13 +1968,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspaceHeader.title}
|
||||
</Text>
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
title={workspaceHeader.title}
|
||||
branchOptions={branchOptions}
|
||||
isOpen={isBranchSwitcherOpen}
|
||||
onOpenChange={setIsBranchSwitcherOpen}
|
||||
onBranchSelect={handleBranchSelect}
|
||||
/>
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
@@ -2350,12 +2371,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
newTabActions: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -103,6 +103,19 @@ export const baseColors = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
|
||||
|
||||
// Diff stat colors — light uses muted tones, dark uses the brighter palette values
|
||||
const lightDiffColors = {
|
||||
diffAddition: "#15803d", // green-700 — readable on white without screaming
|
||||
diffDeletion: "#b91c1c", // red-700
|
||||
};
|
||||
|
||||
const darkDiffColors = {
|
||||
diffAddition: "#4ade80", // green-400
|
||||
diffDeletion: "#ef4444", // red-500
|
||||
};
|
||||
|
||||
// Semantic color tokens - Layer-based system
|
||||
const lightSemanticColors = {
|
||||
// Surfaces (layers) - shifted one step lighter
|
||||
@@ -113,10 +126,11 @@ const lightSemanticColors = {
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
// Text
|
||||
foreground: "#09090b",
|
||||
foreground: "#1a1a1e",
|
||||
foregroundMuted: "#71717a",
|
||||
|
||||
// Controls
|
||||
@@ -140,26 +154,28 @@ const lightSemanticColors = {
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#ffffff",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#09090b",
|
||||
popoverForeground: "#1a1a1e",
|
||||
primary: "#18181b",
|
||||
primaryForeground: "#fafafa",
|
||||
secondary: "#f4f4f5",
|
||||
secondaryForeground: "#09090b",
|
||||
secondaryForeground: "#1a1a1e",
|
||||
muted: "#f4f4f5",
|
||||
mutedForeground: "#71717a",
|
||||
accentBorder: "#ececf1",
|
||||
input: "#f4f4f5",
|
||||
ring: "#18181b",
|
||||
|
||||
...lightDiffColors,
|
||||
|
||||
terminal: {
|
||||
background: "#ffffff",
|
||||
foreground: "#09090b",
|
||||
cursor: "#09090b",
|
||||
foreground: "#1a1a1e",
|
||||
cursor: "#1a1a1e",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "rgba(0, 0, 0, 0.15)",
|
||||
selectionForeground: "#09090b",
|
||||
selectionForeground: "#1a1a1e",
|
||||
|
||||
black: "#09090b",
|
||||
black: "#1a1a1e",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
@@ -179,80 +195,196 @@ const lightSemanticColors = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const darkSemanticColors = {
|
||||
// Surfaces (layers) — subtle teal tint
|
||||
surface0: "#181B1A", // App background
|
||||
surface1: "#1E2120", // Subtle hover
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark theme variant builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Text
|
||||
foreground: "#fafafa",
|
||||
interface DarkThemeConfig {
|
||||
surface0: string;
|
||||
surface1: string;
|
||||
surface2: string;
|
||||
surface3: string;
|
||||
surface4: string;
|
||||
surfaceDiffEmpty: string;
|
||||
surfaceSidebar: string;
|
||||
surfaceSidebarHover: string;
|
||||
foregroundMuted: string;
|
||||
scrollbarHandle: string;
|
||||
border: string;
|
||||
borderAccent: string;
|
||||
accent: string;
|
||||
accentBright: string;
|
||||
}
|
||||
|
||||
const darkTerminalAnsi = {
|
||||
red: "#e07070",
|
||||
green: "#5dba80",
|
||||
yellow: "#d4a44a",
|
||||
blue: "#6a9de0",
|
||||
magenta: "#b07ad0",
|
||||
cyan: "#4aabb8",
|
||||
white: "#d4d4d8",
|
||||
brightRed: "#e89090",
|
||||
brightGreen: "#7ecf9a",
|
||||
brightYellow: "#e0be6e",
|
||||
brightBlue: "#8ab4e8",
|
||||
brightMagenta: "#c49ae0",
|
||||
brightCyan: "#6ec2cc",
|
||||
brightWhite: "#f0f0f2",
|
||||
} as const;
|
||||
|
||||
function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
return {
|
||||
surface0: tint.surface0,
|
||||
surface1: tint.surface1,
|
||||
surface2: tint.surface2,
|
||||
surface3: tint.surface3,
|
||||
surface4: tint.surface4,
|
||||
surfaceDiffEmpty: tint.surfaceDiffEmpty,
|
||||
surfaceSidebar: tint.surfaceSidebar,
|
||||
surfaceSidebarHover: tint.surfaceSidebarHover,
|
||||
surfaceWorkspace: tint.surface1,
|
||||
|
||||
foreground: "#fafafa",
|
||||
foregroundMuted: tint.foregroundMuted,
|
||||
|
||||
scrollbarHandle: tint.scrollbarHandle,
|
||||
|
||||
border: tint.border,
|
||||
borderAccent: tint.borderAccent,
|
||||
|
||||
accent: tint.accent,
|
||||
accentBright: tint.accentBright,
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: tint.accent,
|
||||
successForeground: "#ffffff",
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: tint.surface0,
|
||||
popover: tint.surface2,
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
primaryForeground: tint.surface0,
|
||||
secondary: tint.surface2,
|
||||
secondaryForeground: "#fafafa",
|
||||
muted: tint.surface2,
|
||||
mutedForeground: tint.foregroundMuted,
|
||||
accentBorder: tint.borderAccent,
|
||||
input: tint.surface2,
|
||||
ring: "#d4d4d8",
|
||||
|
||||
...darkDiffColors,
|
||||
|
||||
terminal: {
|
||||
background: tint.surface0,
|
||||
foreground: "#fafafa",
|
||||
cursor: "#fafafa",
|
||||
cursorAccent: tint.surface0,
|
||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||
selectionForeground: "#fafafa",
|
||||
black: tint.surfaceSidebar,
|
||||
...darkTerminalAnsi,
|
||||
brightBlack: tint.surface3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark tint definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Paseo — subtle teal-green tint (default)
|
||||
const paseoDarkColors = buildDarkSemanticColors({
|
||||
surface0: "#181B1A",
|
||||
surface1: "#1E2120",
|
||||
surface2: "#272A29",
|
||||
surface3: "#434645",
|
||||
surface4: "#595B5B",
|
||||
surfaceDiffEmpty: "#252827",
|
||||
surfaceSidebar: "#141716",
|
||||
surfaceSidebarHover: "#1c1f1e",
|
||||
foregroundMuted: "#A1A5A4",
|
||||
|
||||
// Controls
|
||||
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
|
||||
|
||||
// Borders
|
||||
scrollbarHandle: "#717574",
|
||||
border: "#252B2A",
|
||||
borderAccent: "#2F3534",
|
||||
|
||||
// Brand
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
accentForeground: "#ffffff",
|
||||
});
|
||||
|
||||
// Semantic
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: "#20744A",
|
||||
successForeground: "#ffffff",
|
||||
// Zinc — neutral gray, no tint
|
||||
const zincDarkColors = buildDarkSemanticColors({
|
||||
surface0: "#18181b",
|
||||
surface1: "#1f1f22",
|
||||
surface2: "#27272a",
|
||||
surface3: "#3f3f46",
|
||||
surface4: "#52525b",
|
||||
surfaceDiffEmpty: "#242427",
|
||||
surfaceSidebar: "#131316",
|
||||
surfaceSidebarHover: "#1b1b1e",
|
||||
foregroundMuted: "#a1a1aa",
|
||||
scrollbarHandle: "#71717a",
|
||||
border: "#27272a",
|
||||
borderAccent: "#303036",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
});
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#181B1A",
|
||||
popover: "#272A29",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
primaryForeground: "#181B1A",
|
||||
secondary: "#272A29",
|
||||
secondaryForeground: "#fafafa",
|
||||
muted: "#272A29",
|
||||
mutedForeground: "#A1A5A4",
|
||||
accentBorder: "#2F3534",
|
||||
input: "#272A29",
|
||||
ring: "#d4d4d8",
|
||||
// Midnight — subtle blue tint
|
||||
const midnightDarkColors = buildDarkSemanticColors({
|
||||
surface0: "#161820",
|
||||
surface1: "#1c1e27",
|
||||
surface2: "#252731",
|
||||
surface3: "#3c3e4c",
|
||||
surface4: "#535564",
|
||||
surfaceDiffEmpty: "#222430",
|
||||
surfaceSidebar: "#121420",
|
||||
surfaceSidebarHover: "#1a1c28",
|
||||
foregroundMuted: "#9a9db0",
|
||||
scrollbarHandle: "#6b6e82",
|
||||
border: "#242636",
|
||||
borderAccent: "#2e3040",
|
||||
accent: "#3b6fcf",
|
||||
accentBright: "#7eaaeb",
|
||||
});
|
||||
|
||||
terminal: {
|
||||
background: "#181B1A",
|
||||
foreground: "#fafafa",
|
||||
cursor: "#fafafa",
|
||||
cursorAccent: "#181B1A",
|
||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||
selectionForeground: "#fafafa",
|
||||
// Claude — warm neutral with subtle orange undertone
|
||||
const claudeDarkColors = buildDarkSemanticColors({
|
||||
surface0: "#1f1f1e",
|
||||
surface1: "#262523",
|
||||
surface2: "#2f2d2b",
|
||||
surface3: "#4a4745",
|
||||
surface4: "#605d5b",
|
||||
surfaceDiffEmpty: "#2a2826",
|
||||
surfaceSidebar: "#1a1918",
|
||||
surfaceSidebarHover: "#222120",
|
||||
foregroundMuted: "#ada9a5",
|
||||
scrollbarHandle: "#78746f",
|
||||
border: "#2c2a27",
|
||||
borderAccent: "#36332f",
|
||||
accent: "#d97757",
|
||||
accentBright: "#e89a7f",
|
||||
});
|
||||
|
||||
black: "#141716",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#f59e0b",
|
||||
blue: "#3b82f6",
|
||||
magenta: "#a855f7",
|
||||
cyan: "#06b6d4",
|
||||
white: "#e4e4e7",
|
||||
|
||||
brightBlack: "#434645",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#fbbf24",
|
||||
brightBlue: "#60a5fa",
|
||||
brightMagenta: "#c084fc",
|
||||
brightCyan: "#22d3ee",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
} as const;
|
||||
// Ghostty — blue-tinted dark based on Ghostty default background
|
||||
const ghosttyDarkColors = buildDarkSemanticColors({
|
||||
surface0: "#282c34",
|
||||
surface1: "#2f333d",
|
||||
surface2: "#383c48",
|
||||
surface3: "#4a4f5e",
|
||||
surface4: "#5b6175",
|
||||
surfaceDiffEmpty: "#323643",
|
||||
surfaceSidebar: "#21252d",
|
||||
surfaceSidebarHover: "#292d36",
|
||||
foregroundMuted: "#c8ccd8",
|
||||
scrollbarHandle: "#a0a4b2",
|
||||
border: "#353a47",
|
||||
borderAccent: "#3f4454",
|
||||
accent: "#89b4fa",
|
||||
accentBright: "#b4d0fc",
|
||||
});
|
||||
|
||||
const commonTheme = {
|
||||
spacing: {
|
||||
@@ -323,35 +455,45 @@ const commonTheme = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const darkTheme = {
|
||||
colorScheme: "dark" as const,
|
||||
colors: {
|
||||
...darkSemanticColors,
|
||||
palette: baseColors,
|
||||
const darkShadow = {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
md: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.20)",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.40)",
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
md: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.20)",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.40)",
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
...commonTheme,
|
||||
} as const;
|
||||
|
||||
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
|
||||
return {
|
||||
colorScheme: "dark" as const,
|
||||
colors: {
|
||||
...semanticColors,
|
||||
palette: baseColors,
|
||||
},
|
||||
shadow: darkShadow,
|
||||
...commonTheme,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export const darkTheme = buildDarkTheme(paseoDarkColors);
|
||||
export const darkZincTheme = buildDarkTheme(zincDarkColors);
|
||||
export const darkMidnightTheme = buildDarkTheme(midnightDarkColors);
|
||||
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors);
|
||||
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors);
|
||||
|
||||
export const lightTheme = {
|
||||
colorScheme: "light" as const,
|
||||
colors: {
|
||||
@@ -386,3 +528,23 @@ export const theme = darkTheme;
|
||||
|
||||
// Export a union type that works for both themes
|
||||
export type Theme = typeof darkTheme | typeof lightTheme;
|
||||
|
||||
type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty";
|
||||
|
||||
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
|
||||
light: "light",
|
||||
dark: "dark",
|
||||
zinc: "darkZinc",
|
||||
midnight: "darkMidnight",
|
||||
claude: "darkClaude",
|
||||
ghostty: "darkGhostty",
|
||||
};
|
||||
|
||||
export const THEME_SWATCHES: Record<ThemeName, string> = {
|
||||
light: "#ffffff",
|
||||
dark: "#2D8B62",
|
||||
zinc: "#808080",
|
||||
midnight: "#4A6BA8",
|
||||
claude: "#D97757",
|
||||
ghostty: "#8caaee",
|
||||
};
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
// import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { lightTheme, darkTheme } from "./theme";
|
||||
import {
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
darkZincTheme,
|
||||
darkMidnightTheme,
|
||||
darkClaudeTheme,
|
||||
darkGhosttyTheme,
|
||||
} from "./theme";
|
||||
|
||||
StyleSheet.configure({
|
||||
themes: {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
darkZinc: darkZincTheme,
|
||||
darkMidnight: darkMidnightTheme,
|
||||
darkClaude: darkClaudeTheme,
|
||||
darkGhostty: darkGhosttyTheme,
|
||||
},
|
||||
breakpoints: {
|
||||
xs: 0,
|
||||
@@ -23,6 +33,10 @@ StyleSheet.configure({
|
||||
type AppThemes = {
|
||||
light: typeof lightTheme;
|
||||
dark: typeof darkTheme;
|
||||
darkZinc: typeof darkZincTheme;
|
||||
darkMidnight: typeof darkMidnightTheme;
|
||||
darkClaude: typeof darkClaudeTheme;
|
||||
darkGhostty: typeof darkGhosttyTheme;
|
||||
};
|
||||
|
||||
type AppBreakpoints = {
|
||||
@@ -37,5 +51,3 @@ declare module "react-native-unistyles" {
|
||||
export interface UnistylesThemes extends AppThemes {}
|
||||
export interface UnistylesBreakpoints extends AppBreakpoints {}
|
||||
}
|
||||
|
||||
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSplitDiffRows } from "./diff-layout";
|
||||
import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||
@@ -79,3 +79,76 @@ describe("buildSplitDiffRows", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnifiedDiffLines", () => {
|
||||
it("computes line numbers per line type within a hunk", () => {
|
||||
const lines = buildUnifiedDiffLines(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,3 +10,4 @@" },
|
||||
{ type: "context", content: "before" },
|
||||
{ type: "add", content: "inserted" },
|
||||
{ type: "remove", content: "removed" },
|
||||
{ type: "context", content: "after" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
lines.map(({ line, lineNumber }) => ({
|
||||
type: line.type,
|
||||
lineNumber,
|
||||
content: line.content,
|
||||
})),
|
||||
).toEqual([
|
||||
{ type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" },
|
||||
{ type: "context", lineNumber: 10, content: "before" },
|
||||
{ type: "add", lineNumber: 11, content: "inserted" },
|
||||
{ type: "remove", lineNumber: 11, content: "removed" },
|
||||
{ type: "context", lineNumber: 12, content: "after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("restarts numbering at each hunk boundary", () => {
|
||||
const file: ParsedDiffFile = {
|
||||
path: "example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 75,
|
||||
oldCount: 2,
|
||||
newStart: 75,
|
||||
newCount: 3,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -75,2 +75,3 @@" },
|
||||
{ type: "context", content: "first" },
|
||||
{ type: "add", content: "inserted" },
|
||||
{ type: "context", content: "second" },
|
||||
],
|
||||
},
|
||||
{
|
||||
oldStart: 165,
|
||||
oldCount: 2,
|
||||
newStart: 166,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -165,2 +166,2 @@" },
|
||||
{ type: "context", content: "third" },
|
||||
{ type: "context", content: "fourth" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const lines = buildUnifiedDiffLines(file);
|
||||
|
||||
expect(lines[0]?.lineNumber).toBeNull();
|
||||
expect(lines[1]?.lineNumber).toBe(75);
|
||||
expect(lines[3]?.lineNumber).toBe(77);
|
||||
expect(lines[4]?.lineNumber).toBeNull();
|
||||
expect(lines[5]?.lineNumber).toBe(166);
|
||||
expect(lines[6]?.lineNumber).toBe(167);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface SplitDiffDisplayLine {
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffDisplayLine {
|
||||
key: string;
|
||||
line: DiffLine;
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
| {
|
||||
kind: "header";
|
||||
@@ -61,6 +67,39 @@ function toDisplayLine(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
||||
const lines: UnifiedDiffDisplayLine[] = [];
|
||||
|
||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
|
||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
||||
let lineNumber: number | null = null;
|
||||
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNo += 1;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNo += 1;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
|
||||
lines.push({
|
||||
key: `${hunkIndex}-${lineIndex}`,
|
||||
line,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
|
||||
23
packages/app/src/utils/diff-rendering.test.ts
Normal file
23
packages/app/src/utils/diff-rendering.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering";
|
||||
|
||||
describe("diff-rendering", () => {
|
||||
it("keeps header gutters tall even when they do not show a line number", () => {
|
||||
expect(formatDiffGutterText(null)).toBe(" ");
|
||||
expect(formatDiffGutterText(82)).toBe("82");
|
||||
});
|
||||
|
||||
it("keeps empty split cells tall even when they have no visible content", () => {
|
||||
expect(formatDiffContentText(undefined)).toBe(" ");
|
||||
expect(formatDiffContentText("")).toBe(" ");
|
||||
expect(formatDiffContentText("const value = 1;")).toBe("const value = 1;");
|
||||
});
|
||||
|
||||
it("treats empty highlighted token rows as blank lines instead of visible content", () => {
|
||||
expect(hasVisibleDiffTokens(undefined)).toBe(false);
|
||||
expect(hasVisibleDiffTokens([])).toBe(false);
|
||||
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
|
||||
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
|
||||
});
|
||||
});
|
||||
16
packages/app/src/utils/diff-rendering.ts
Normal file
16
packages/app/src/utils/diff-rendering.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
interface HighlightLikeToken {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Preserve row height when a gutter or diff cell is intentionally blank.
|
||||
export function formatDiffGutterText(lineNumber: number | null): string {
|
||||
return lineNumber == null ? " " : String(lineNumber);
|
||||
}
|
||||
|
||||
export function formatDiffContentText(content: string | null | undefined): string {
|
||||
return content && content.length > 0 ? content : " ";
|
||||
}
|
||||
|
||||
export function hasVisibleDiffTokens(tokens: HighlightLikeToken[] | null | undefined): boolean {
|
||||
return Boolean(tokens?.some((token) => token.text.length > 0));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("treats initializing agents as running", () => {
|
||||
it("treats initializing agents as done", () => {
|
||||
expect(
|
||||
deriveSidebarStateBucket({
|
||||
status: "initializing",
|
||||
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("running");
|
||||
).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: {
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running" || input.status === "initializing") {
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("terminal key helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("intercepts special keys and modifier combos", () => {
|
||||
it("only intercepts when pending modifiers are active", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Escape",
|
||||
@@ -60,7 +60,7 @@ describe("terminal key helpers", () => {
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
@@ -68,15 +68,23 @@ describe("terminal key helpers", () => {
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Escape",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects pending modifier state", () => {
|
||||
|
||||
@@ -88,12 +88,7 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
altKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
}): boolean {
|
||||
return (
|
||||
args.key.length > 1 ||
|
||||
args.ctrlKey ||
|
||||
args.altKey ||
|
||||
hasPendingTerminalModifiers(args.pendingModifiers)
|
||||
);
|
||||
return hasPendingTerminalModifiers(args.pendingModifiers);
|
||||
}
|
||||
|
||||
export function mergeTerminalModifiers(args: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@getpaseo/cli": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
28
packages/desktop/scripts/dev.sh
Executable file
28
packages/desktop/scripts/dev.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
|
||||
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
||||
export EXPO_PORT
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
"$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
--kill-others \
|
||||
--names "metro,electron" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
"cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \
|
||||
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
|
||||
@@ -7,6 +7,7 @@ import { autoUpdater, type UpdateInfo } from "electron-updater";
|
||||
|
||||
export type AppUpdateCheckResult = {
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
body: string | null;
|
||||
@@ -24,19 +25,84 @@ export type AppUpdateInstallResult = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedUpdateInfo: UpdateInfo | null = null;
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
let downloading = false;
|
||||
let autoUpdaterConfigured = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function configureAutoUpdater(): void {
|
||||
// Don't auto-download — the user triggers install explicitly.
|
||||
autoUpdater.autoDownload = false;
|
||||
// Download updates in the background and only prompt once they are ready to install.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Suppress built-in dialogs; the renderer handles UI.
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
|
||||
if (autoUpdaterConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoUpdaterConfigured = true;
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = true;
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = info.version;
|
||||
downloading = false;
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
downloading = false;
|
||||
console.error("[auto-updater] Updater event failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function isReadyToInstallVersion(version: string): boolean {
|
||||
return downloadedUpdateVersion === version;
|
||||
}
|
||||
|
||||
function buildCheckResult(input: {
|
||||
currentVersion: string;
|
||||
hasUpdate: boolean;
|
||||
readyToInstall: boolean;
|
||||
info?: UpdateInfo | null;
|
||||
}): AppUpdateCheckResult {
|
||||
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
readyToInstall,
|
||||
currentVersion,
|
||||
latestVersion: info?.version ?? currentVersion,
|
||||
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
||||
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,28 +111,34 @@ function configureAutoUpdater(): void {
|
||||
|
||||
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
configureAutoUpdater();
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
||||
info: cachedUpdateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
|
||||
if (!result || !result.updateInfo) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
|
||||
const info = result.updateInfo;
|
||||
@@ -75,24 +147,31 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
|
||||
|
||||
if (hasUpdate) {
|
||||
cachedUpdateInfo = info;
|
||||
downloading = !isReadyToInstallVersion(latestVersion);
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
||||
info,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
cachedUpdateInfo = null;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = false;
|
||||
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
|
||||
date: typeof info.releaseDate === "string" ? info.releaseDate : null,
|
||||
};
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] Failed to check for updates:", error);
|
||||
return {
|
||||
hasUpdate: false,
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,14 +187,6 @@ export async function downloadAndInstallUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update already in progress.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!cachedUpdateInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
@@ -126,24 +197,35 @@ export async function downloadAndInstallUpdate(
|
||||
|
||||
configureAutoUpdater();
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update is still being prepared. Try again in a moment.",
|
||||
};
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
// quitAndInstall restarts the app with the new version.
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: cachedUpdateInfo.version,
|
||||
version: readyVersion,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ inheritLoginShellEnv();
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import {
|
||||
@@ -38,6 +39,37 @@ const APP_SCHEME = "paseo";
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
app.setName("Paseo");
|
||||
|
||||
// In dev mode, detect git worktrees and isolate each instance so multiple
|
||||
// Electron windows can run side-by-side (separate userData = separate lock).
|
||||
let devWorktreeName: string | null = null;
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
}).trim();
|
||||
devWorktreeName = path.basename(topLevel);
|
||||
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
|
||||
const commonDir = path.resolve(
|
||||
topLevel,
|
||||
execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: topLevel,
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
}).trim(),
|
||||
);
|
||||
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
|
||||
if (isWorktree) {
|
||||
app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`));
|
||||
log.info("[worktree] isolated userData for worktree:", devWorktreeName);
|
||||
} else {
|
||||
devWorktreeName = null;
|
||||
}
|
||||
} catch {
|
||||
devWorktreeName = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
||||
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
||||
// Must run before app.whenReady().
|
||||
@@ -127,7 +159,9 @@ async function createMainWindow(): Promise<void> {
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
|
||||
const mainWindow = new BrowserWindow({
|
||||
title,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
@@ -144,6 +178,10 @@ async function createMainWindow(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
if (devWorktreeName) {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -18,3 +18,6 @@ PASEO_LISTEN=127.0.0.1:6767
|
||||
# Debug recordings (dictation + STT input + TTS output)
|
||||
# When enabled, recordings are saved under `${cwd}/.debug/recordings/`
|
||||
PASEO_DICTATION_DEBUG=1
|
||||
|
||||
# Enable verbose Claude SDK stream logging (trace-level per-token logs)
|
||||
PASEO_CLAUDE_DEBUG=1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.51-rc.1",
|
||||
"version": "0.1.52",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -28,6 +28,10 @@ import type {
|
||||
CheckoutPushResponse,
|
||||
CheckoutPrCreateResponse,
|
||||
CheckoutPrStatusResponse,
|
||||
CheckoutSwitchBranchResponse,
|
||||
StashSaveResponse,
|
||||
StashPopResponse,
|
||||
StashListResponse,
|
||||
ValidateBranchResponse,
|
||||
BranchSuggestionsResponse,
|
||||
DirectorySuggestionsResponse,
|
||||
@@ -217,6 +221,10 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||
type StashSavePayload = StashSaveResponse["payload"];
|
||||
type StashPopPayload = StashPopResponse["payload"];
|
||||
type StashListPayload = StashListResponse["payload"];
|
||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||
@@ -2374,6 +2382,74 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async checkoutSwitchBranch(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
requestId?: string,
|
||||
): Promise<CheckoutSwitchBranchPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout_switch_branch_request",
|
||||
cwd,
|
||||
branch,
|
||||
},
|
||||
responseType: "checkout_switch_branch_response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async stashSave(
|
||||
cwd: string,
|
||||
options?: { branch?: string },
|
||||
requestId?: string,
|
||||
): Promise<StashSavePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "stash_save_request",
|
||||
cwd,
|
||||
branch: options?.branch,
|
||||
},
|
||||
responseType: "stash_save_response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async stashPop(
|
||||
cwd: string,
|
||||
stashIndex: number,
|
||||
requestId?: string,
|
||||
): Promise<StashPopPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "stash_pop_request",
|
||||
cwd,
|
||||
stashIndex,
|
||||
},
|
||||
responseType: "stash_pop_response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async stashList(
|
||||
cwd: string,
|
||||
options?: { paseoOnly?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<StashListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "stash_list_request",
|
||||
cwd,
|
||||
paseoOnly: options?.paseoOnly,
|
||||
},
|
||||
responseType: "stash_list_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getPaseoWorktreeList(
|
||||
input: { cwd?: string; repoRoot?: string },
|
||||
requestId?: string,
|
||||
|
||||
@@ -415,10 +415,39 @@ describe("transformPiModels", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentSession slash commands", () => {
|
||||
test("caches ACP available commands for listCommands", async () => {
|
||||
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
|
||||
const session = createSession();
|
||||
|
||||
expect(await session.listCommands()).toEqual([]);
|
||||
await expect(session.listCommands()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("waits for async available_commands_update when enabled", async () => {
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
defaultModes: [],
|
||||
modelTransformer: transformPiModels,
|
||||
sessionResponseTransformer: transformPiSessionResponse,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
},
|
||||
);
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
(session as any).translateSessionUpdate({
|
||||
sessionUpdate: "available_commands_update",
|
||||
@@ -434,6 +463,19 @@ describe("ACPAgentSession slash commands", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([
|
||||
{
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
argumentHint: "",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await session.listCommands()).toEqual([
|
||||
{
|
||||
name: "research_codebase",
|
||||
|
||||
@@ -125,6 +125,8 @@ type ACPAgentClientOptions = {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type ACPAgentSessionOptions = {
|
||||
@@ -144,6 +146,8 @@ type ACPAgentSessionOptions = {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
handle?: AgentPersistenceHandle;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type SpawnedACPProcess = {
|
||||
@@ -302,6 +306,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
private readonly waitForInitialCommands: boolean;
|
||||
private readonly initialCommandsWaitTimeoutMs: number;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
@@ -314,6 +320,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -335,6 +343,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
@@ -375,6 +385,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
capabilities: this.capabilities,
|
||||
handle,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
await session.initializeResumedSession();
|
||||
return session;
|
||||
@@ -617,6 +629,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
|
||||
private commandsReadySettled = false;
|
||||
private waitForInitialCommands: boolean;
|
||||
private initialCommandsWaitTimeoutMs: number;
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -645,6 +661,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.currentModel = config.model ?? null;
|
||||
this.thinkingOptionId = config.thinkingOptionId ?? null;
|
||||
this.currentTitle = config.title ?? null;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -876,7 +894,59 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
this.commandsReadyDeferred = { promise, resolve };
|
||||
}
|
||||
|
||||
private settleCommandsReady(): void {
|
||||
if (this.commandsReadySettled) {
|
||||
return;
|
||||
}
|
||||
this.commandsReadySettled = true;
|
||||
this.commandsReadyDeferred?.resolve();
|
||||
this.commandsReadyDeferred = null;
|
||||
}
|
||||
|
||||
private async waitForCommandsReady(): Promise<void> {
|
||||
const deferred = this.commandsReadyDeferred;
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
deferred.promise,
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
if (this.cachedCommands.length > 0) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
if (!this.waitForInitialCommands || this.closed) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
this.ensureCommandsReadyDeferred();
|
||||
await this.waitForCommandsReady();
|
||||
this.settleCommandsReady();
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
@@ -1046,6 +1116,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
this.closed = true;
|
||||
|
||||
this.settleCommandsReady();
|
||||
|
||||
for (const pending of this.pendingPermissions.values()) {
|
||||
pending.resolve({ outcome: { outcome: "cancelled" } });
|
||||
}
|
||||
@@ -1392,6 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
|
||||
@@ -1047,6 +1047,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
};
|
||||
}
|
||||
|
||||
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
|
||||
|
||||
export class ClaudeAgentClient implements AgentClient {
|
||||
readonly provider: "claude" = "claude";
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
@@ -2454,15 +2456,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
while (!this.closed && this.query === activeQuery) {
|
||||
try {
|
||||
for await (const message of activeQuery) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||
},
|
||||
"Claude query pump: raw SDK message",
|
||||
);
|
||||
if (claudeDebug) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||
},
|
||||
"Claude query pump: raw SDK message",
|
||||
);
|
||||
}
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return;
|
||||
@@ -2538,14 +2542,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
const identifiers = readEventIdentifiers(message);
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
turnId,
|
||||
},
|
||||
"Claude query pump: SDK message",
|
||||
);
|
||||
if (claudeDebug) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
turnId,
|
||||
},
|
||||
"Claude query pump: SDK message",
|
||||
);
|
||||
}
|
||||
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
|
||||
@@ -315,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
});
|
||||
|
||||
describe("OpenCode adapter context-window normalization", () => {
|
||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||
expect(
|
||||
__openCodeInternals.buildOpenCodePromptParts([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "attachment-1.png",
|
||||
url: "data:image/png;base64,YWJjMTIz",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves provider catalog context limit in model metadata", () => {
|
||||
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
type OpencodeClient,
|
||||
type Part as OpenCodePart,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import net from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
@@ -494,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
||||
].some((value) => typeof value === "number" && Number.isFinite(value));
|
||||
}
|
||||
|
||||
function getOpenCodeAttachmentExtension(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case "image/png":
|
||||
return "png";
|
||||
case "image/jpeg":
|
||||
return "jpg";
|
||||
case "image/webp":
|
||||
return "webp";
|
||||
case "image/gif":
|
||||
return "gif";
|
||||
case "image/svg+xml":
|
||||
return "svg";
|
||||
default:
|
||||
return "bin";
|
||||
}
|
||||
}
|
||||
|
||||
function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } {
|
||||
const match = data.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
return {
|
||||
mimeType: match[1] ?? mimeType,
|
||||
url: data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mimeType,
|
||||
url: `data:${mimeType};base64,${data}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePromptParts(
|
||||
prompt: AgentPromptInput,
|
||||
): Array<OpenCodeTextPartInput | OpenCodeFilePartInput> {
|
||||
if (typeof prompt === "string") {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
let attachmentOrdinal = 0;
|
||||
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
|
||||
for (const part of prompt) {
|
||||
if (part.type === "text") {
|
||||
output.push({ type: "text", text: part.text });
|
||||
continue;
|
||||
}
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export const __openCodeInternals = {
|
||||
buildOpenCodePromptParts,
|
||||
buildOpenCodeModelContextWindowLookup,
|
||||
buildOpenCodeModelDefinition,
|
||||
buildOpenCodeModelLookupKey,
|
||||
@@ -1436,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
|
||||
const parts = this.buildPromptParts(prompt);
|
||||
const parts = buildOpenCodePromptParts(prompt);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const thinkingOptionId = this.config.thinkingOptionId;
|
||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||
@@ -1862,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = null;
|
||||
}
|
||||
|
||||
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
|
||||
if (typeof prompt === "string") {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
return prompt
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => ({ type: "text", text: p.text }));
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
|
||||
@@ -298,6 +298,8 @@ export class PiACPAgentClient extends ACPAgentClient {
|
||||
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
|
||||
},
|
||||
capabilities: PI_CAPABILITIES,
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function createPaseoDaemon(
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && allowedOrigins.has(origin)) {
|
||||
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
} from "./test-utils/index.js";
|
||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentPersistenceHandle,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||
|
||||
@@ -94,6 +102,116 @@ function waitForSignal<T>(
|
||||
});
|
||||
}
|
||||
|
||||
class NonPersistentReloadSession implements AgentSession {
|
||||
readonly provider = "claude" as const;
|
||||
readonly id = null;
|
||||
readonly capabilities = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
constructor(private readonly onClose: () => void) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return {
|
||||
sessionId: "non-persistent",
|
||||
finalText: "",
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
return { turnId: "non-persistent-turn" };
|
||||
}
|
||||
|
||||
subscribe(_callback: (event: AgentStreamEvent) => void): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
return;
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: "claude" as const,
|
||||
sessionId: null,
|
||||
model: null,
|
||||
modeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(_modeId: string): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
class NonPersistentReloadClient implements AgentClient {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
createSessionCalls = 0;
|
||||
resumeSessionCalls = 0;
|
||||
closeCalls = 0;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createSessionCalls += 1;
|
||||
return new NonPersistentReloadSession(() => {
|
||||
this.closeCalls += 1;
|
||||
});
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
_overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeSessionCalls += 1;
|
||||
return new NonPersistentReloadSession(() => {
|
||||
this.closeCalls += 1;
|
||||
});
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("daemon client E2E", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
@@ -279,6 +397,38 @@ describe("daemon client E2E", () => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new NonPersistentReloadClient();
|
||||
const localCtx = await createDaemonTestContext({
|
||||
agentClients: {
|
||||
claude: client,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await localCtx.client.createAgent({
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(client.createSessionCalls).toBe(1);
|
||||
expect(client.resumeSessionCalls).toBe(0);
|
||||
expect(client.closeCalls).toBe(0);
|
||||
|
||||
await localCtx.client.refreshAgent(created.id);
|
||||
|
||||
expect(client.createSessionCalls).toBe(2);
|
||||
expect(client.resumeSessionCalls).toBe(0);
|
||||
expect(client.closeCalls).toBe(1);
|
||||
} finally {
|
||||
await localCtx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resume_agent auto-unarchives archived agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
|
||||
@@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
||||
|
||||
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
|
||||
const DEFAULT_FILE_LEVEL: LogLevel = "trace";
|
||||
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
||||
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
|
||||
@@ -231,8 +231,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
|
||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||
|
||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||
@@ -296,7 +294,6 @@ type ActiveTerminalStream = {
|
||||
slot: number;
|
||||
unsubscribe: () => void;
|
||||
needsSnapshot: boolean;
|
||||
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export type SessionRuntimeMetrics = {
|
||||
@@ -408,7 +405,6 @@ export type SessionOptions = {
|
||||
appVersion: string | null;
|
||||
onMessage: (msg: SessionOutboundMessage) => void;
|
||||
onBinaryMessage?: (frame: Uint8Array) => void;
|
||||
getBinaryBufferedAmount?: () => number;
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||||
logger: pino.Logger;
|
||||
downloadTokenStore: DownloadTokenStore;
|
||||
@@ -563,7 +559,6 @@ export class Session {
|
||||
private readonly sessionId: string;
|
||||
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
||||
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
||||
private readonly getBinaryBufferedAmount: (() => number) | null;
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
@@ -662,7 +657,6 @@ export class Session {
|
||||
appVersion,
|
||||
onMessage,
|
||||
onBinaryMessage,
|
||||
getBinaryBufferedAmount,
|
||||
onLifecycleIntent,
|
||||
logger,
|
||||
downloadTokenStore,
|
||||
@@ -692,7 +686,6 @@ export class Session {
|
||||
this.sessionId = uuidv4();
|
||||
this.onMessage = onMessage;
|
||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.pushTokenStore = pushTokenStore;
|
||||
@@ -1746,6 +1739,22 @@ export class Session {
|
||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout_switch_branch_request":
|
||||
await this.handleCheckoutSwitchBranchRequest(msg);
|
||||
break;
|
||||
|
||||
case "stash_save_request":
|
||||
await this.handleStashSaveRequest(msg);
|
||||
break;
|
||||
|
||||
case "stash_pop_request":
|
||||
await this.handleStashPopRequest(msg);
|
||||
break;
|
||||
|
||||
case "stash_list_request":
|
||||
await this.handleStashListRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout_commit_request":
|
||||
await this.handleCheckoutCommitRequest(msg);
|
||||
break;
|
||||
@@ -3095,11 +3104,7 @@ export class Session {
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
if (existing.persistence) {
|
||||
snapshot = await this.agentManager.reloadAgentSession(agentId);
|
||||
} else {
|
||||
snapshot = existing;
|
||||
}
|
||||
snapshot = await this.agentManager.reloadAgentSession(agentId);
|
||||
} else {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
@@ -4431,6 +4436,139 @@ export class Session {
|
||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private async handleCheckoutSwitchBranchRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, branch, requestId } = msg;
|
||||
|
||||
try {
|
||||
await this.checkoutExistingBranch(cwd, branch);
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
|
||||
// Push a workspace_update immediately so the sidebar/header reflect
|
||||
// the new branch name without waiting for the background git watcher.
|
||||
await this.emitWorkspaceUpdateForCwd(cwd);
|
||||
|
||||
this.emit({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: true,
|
||||
branch,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
branch,
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stash handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
||||
|
||||
private async handleStashSaveRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
try {
|
||||
const branchLabel = msg.branch?.trim() ?? "";
|
||||
const message = branchLabel
|
||||
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
||||
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
||||
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_save_response",
|
||||
payload: { cwd, success: true, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "stash_save_response",
|
||||
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStashPopRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, stashIndex, requestId } = msg;
|
||||
try {
|
||||
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_pop_response",
|
||||
payload: { cwd, success: true, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "stash_pop_response",
|
||||
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStashListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
const paseoOnly = msg.paseoOnly !== false;
|
||||
try {
|
||||
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||
const entries: Array<{
|
||||
index: number;
|
||||
message: string;
|
||||
branch: string | null;
|
||||
isPaseo: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const sepIdx = line.indexOf("\0");
|
||||
if (sepIdx < 0) continue;
|
||||
const refPart = line.slice(0, sepIdx);
|
||||
const subject = line.slice(sepIdx + 1);
|
||||
const indexMatch = refPart.match(/\{(\d+)\}/);
|
||||
if (!indexMatch) continue;
|
||||
const index = Number(indexMatch[1]);
|
||||
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
|
||||
const isPaseo = prefixIdx >= 0;
|
||||
const branch = isPaseo
|
||||
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
|
||||
: null;
|
||||
|
||||
if (paseoOnly && !isPaseo) continue;
|
||||
entries.push({ index, message: subject, branch, isPaseo });
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "stash_list_response",
|
||||
payload: { cwd, entries, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "stash_list_response",
|
||||
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutCommitRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -5326,7 +5464,7 @@ export class Session {
|
||||
if (agent.status === "error" || agent.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (agent.status === "running" || agent.status === "initializing") {
|
||||
if (agent.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (agent.requiresAttention) {
|
||||
@@ -8279,7 +8417,6 @@ export class Session {
|
||||
slot,
|
||||
unsubscribe: () => {},
|
||||
needsSnapshot: true,
|
||||
snapshotRetryTimer: null,
|
||||
};
|
||||
|
||||
this.activeTerminalStreams.set(slot, activeStream);
|
||||
@@ -8296,10 +8433,6 @@ export class Session {
|
||||
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||
this.markAllActiveTerminalStreamsForSnapshot();
|
||||
return;
|
||||
}
|
||||
this.emitBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
@@ -8307,9 +8440,6 @@ export class Session {
|
||||
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
||||
}),
|
||||
);
|
||||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||
this.markAllActiveTerminalStreamsForSnapshot();
|
||||
}
|
||||
});
|
||||
return slot;
|
||||
}
|
||||
@@ -8322,21 +8452,6 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
|
||||
if (!activeStream.snapshotRetryTimer) {
|
||||
activeStream.snapshotRetryTimer = setTimeout(() => {
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
}, 33);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStream.snapshotRetryTimer) {
|
||||
clearTimeout(activeStream.snapshotRetryTimer);
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
}
|
||||
|
||||
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
||||
if (!terminal) {
|
||||
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
|
||||
@@ -8353,13 +8468,6 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
private markAllActiveTerminalStreamsForSnapshot(): void {
|
||||
for (const activeStream of this.activeTerminalStreams.values()) {
|
||||
activeStream.needsSnapshot = true;
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
}
|
||||
}
|
||||
|
||||
private allocateTerminalSlot(): number | null {
|
||||
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
|
||||
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
|
||||
@@ -8384,10 +8492,6 @@ export class Session {
|
||||
}
|
||||
this.activeTerminalStreams.delete(slot);
|
||||
this.terminalIdToSlot.delete(terminalId);
|
||||
if (activeStream.snapshotRetryTimer) {
|
||||
clearTimeout(activeStream.snapshotRetryTimer);
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
}
|
||||
try {
|
||||
activeStream.unsubscribe();
|
||||
} catch (error) {
|
||||
@@ -8410,11 +8514,4 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentBinaryBufferedAmount(): number {
|
||||
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
|
||||
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(bufferedAmount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
!!requestHost &&
|
||||
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
|
||||
|
||||
if (!origin || allowedOrigins.has(origin) || sameOrigin) {
|
||||
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
|
||||
callback(true);
|
||||
} else {
|
||||
this.incrementRuntimeCounter("originRejected");
|
||||
@@ -634,16 +634,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
this.sendBinaryToConnection(connection, frame);
|
||||
},
|
||||
getBinaryBufferedAmount: () => {
|
||||
if (!connection) {
|
||||
return 0;
|
||||
}
|
||||
let bufferedAmount = 0;
|
||||
for (const socket of connection.sockets) {
|
||||
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
|
||||
}
|
||||
return bufferedAmount;
|
||||
},
|
||||
onLifecycleIntent: (intent) => {
|
||||
this.onLifecycleIntent?.(intent);
|
||||
},
|
||||
|
||||
@@ -1057,6 +1057,37 @@ export const ValidateBranchRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutSwitchBranchRequestSchema = z.object({
|
||||
type: z.literal("checkout_switch_branch_request"),
|
||||
cwd: z.string(),
|
||||
branch: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StashSaveRequestSchema = z.object({
|
||||
type: z.literal("stash_save_request"),
|
||||
cwd: z.string(),
|
||||
/** Branch name to tag the stash with for later identification. */
|
||||
branch: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StashPopRequestSchema = z.object({
|
||||
type: z.literal("stash_pop_request"),
|
||||
cwd: z.string(),
|
||||
/** Zero-based index from stash_list_response. */
|
||||
stashIndex: z.number().int().min(0),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StashListRequestSchema = z.object({
|
||||
type: z.literal("stash_list_request"),
|
||||
cwd: z.string(),
|
||||
/** If true, only return paseo-created stashes. Default true. */
|
||||
paseoOnly: z.boolean().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const BranchSuggestionsRequestSchema = z.object({
|
||||
type: z.literal("branch_suggestions_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1392,6 +1423,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPushRequestSchema,
|
||||
CheckoutPrCreateRequestSchema,
|
||||
CheckoutPrStatusRequestSchema,
|
||||
CheckoutSwitchBranchRequestSchema,
|
||||
StashSaveRequestSchema,
|
||||
StashPopRequestSchema,
|
||||
StashListRequestSchema,
|
||||
ValidateBranchRequestSchema,
|
||||
BranchSuggestionsRequestSchema,
|
||||
DirectorySuggestionsRequestSchema,
|
||||
@@ -2192,6 +2227,54 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutSwitchBranchResponseSchema = z.object({
|
||||
type: z.literal("checkout_switch_branch_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
branch: z.string(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const StashEntrySchema = z.object({
|
||||
index: z.number().int().min(0),
|
||||
message: z.string(),
|
||||
branch: z.string().nullable(),
|
||||
isPaseo: z.boolean(),
|
||||
});
|
||||
|
||||
export const StashSaveResponseSchema = z.object({
|
||||
type: z.literal("stash_save_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StashPopResponseSchema = z.object({
|
||||
type: z.literal("stash_pop_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StashListResponseSchema = z.object({
|
||||
type: z.literal("stash_list_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
entries: z.array(StashEntrySchema),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ValidateBranchResponseSchema = z.object({
|
||||
type: z.literal("validate_branch_response"),
|
||||
payload: z.object({
|
||||
@@ -2578,6 +2661,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPushResponseSchema,
|
||||
CheckoutPrCreateResponseSchema,
|
||||
CheckoutPrStatusResponseSchema,
|
||||
CheckoutSwitchBranchResponseSchema,
|
||||
StashSaveResponseSchema,
|
||||
StashPopResponseSchema,
|
||||
StashListResponseSchema,
|
||||
ValidateBranchResponseSchema,
|
||||
BranchSuggestionsResponseSchema,
|
||||
DirectorySuggestionsResponseSchema,
|
||||
@@ -2793,6 +2880,15 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
|
||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
||||
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
||||
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
||||
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
||||
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
||||
export type StashPopResponse = z.infer<typeof StashPopResponseSchema>;
|
||||
export type StashListRequest = z.infer<typeof StashListRequestSchema>;
|
||||
export type StashListResponse = z.infer<typeof StashListResponseSchema>;
|
||||
export type StashEntry = z.infer<typeof StashEntrySchema>;
|
||||
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||
|
||||
@@ -157,7 +157,6 @@ type CheckoutFileChange = {
|
||||
isUntracked?: boolean;
|
||||
};
|
||||
|
||||
type BranchSuggestionRefOrigin = "local" | "remote";
|
||||
|
||||
function normalizeBranchSuggestionName(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
@@ -176,47 +175,57 @@ function normalizeBranchSuggestionName(raw: string): string | null {
|
||||
normalized = normalized.slice("origin/".length);
|
||||
}
|
||||
|
||||
if (!normalized || normalized === "HEAD") {
|
||||
if (!normalized || normalized === "HEAD" || normalized === "origin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
|
||||
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
interface GitRef {
|
||||
name: string;
|
||||
committerDate: number;
|
||||
}
|
||||
|
||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||
const { stdout } = await execGit(
|
||||
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const [name, dateStr] = trimmed.split("\t");
|
||||
if (!name) return null;
|
||||
return { name, committerDate: Number(dateStr) || 0 };
|
||||
})
|
||||
.filter((ref): ref is GitRef => ref !== null);
|
||||
}
|
||||
|
||||
function sortBranchSuggestions(
|
||||
branchNames: string[],
|
||||
localBranchNames: Set<string>,
|
||||
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
|
||||
query: string,
|
||||
): string[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const hasQuery = normalizedQuery.length > 0;
|
||||
return branchNames.sort((a, b) => {
|
||||
const aLower = a.toLowerCase();
|
||||
const bLower = b.toLowerCase();
|
||||
|
||||
if (hasQuery) {
|
||||
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||
const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
|
||||
const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
|
||||
if (aPrefix !== bPrefix) {
|
||||
return aPrefix ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const aIsLocal = localBranchNames.has(a);
|
||||
const bIsLocal = localBranchNames.has(b);
|
||||
if (aIsLocal !== bIsLocal) {
|
||||
return aIsLocal ? -1 : 1;
|
||||
const aMeta = branchMeta.get(a);
|
||||
const bMeta = branchMeta.get(b);
|
||||
const aDate = aMeta?.committerDate ?? 0;
|
||||
const bDate = bMeta?.committerDate ?? 0;
|
||||
if (aDate !== bDate) {
|
||||
return bDate - aDate;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
@@ -238,41 +247,40 @@ export async function listBranchSuggestions(
|
||||
listGitRefs(cwd, "refs/remotes/origin"),
|
||||
]);
|
||||
|
||||
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
|
||||
for (const localRef of localRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(localRef);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||
origins.add("local");
|
||||
merged.set(normalized, origins);
|
||||
}
|
||||
for (const remoteRef of remoteRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(remoteRef);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||
origins.add("remote");
|
||||
merged.set(normalized, origins);
|
||||
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
|
||||
|
||||
for (const ref of localRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(ref.name);
|
||||
if (!normalized) continue;
|
||||
const existing = branchMeta.get(normalized);
|
||||
branchMeta.set(normalized, {
|
||||
isLocal: true,
|
||||
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
const filteredNames = Array.from(merged.keys()).filter((name) =>
|
||||
for (const ref of remoteRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(ref.name);
|
||||
if (!normalized) continue;
|
||||
const existing = branchMeta.get(normalized);
|
||||
if (!existing) {
|
||||
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
|
||||
} else {
|
||||
branchMeta.set(normalized, {
|
||||
...existing,
|
||||
committerDate: Math.max(ref.committerDate, existing.committerDate),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
|
||||
query ? name.toLowerCase().includes(query) : true,
|
||||
);
|
||||
if (filteredNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localBranchNames = new Set<string>();
|
||||
for (const [name, origins] of merged) {
|
||||
if (origins.has("local")) {
|
||||
localBranchNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
|
||||
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
|
||||
return ordered.slice(0, limit);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user