mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3876ffe61 | ||
|
|
8f136a1f92 |
@@ -1,171 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
format:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: npx biome format .
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Typecheck all packages
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
server-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Fetch origin/main (worktree tests)
|
|
||||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run server tests
|
|
||||||
run: npm run test --workspace=@getpaseo/server
|
|
||||||
env:
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
|
|
||||||
app-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Run app unit tests
|
|
||||||
run: npm run test --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
playwright:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app
|
|
||||||
env:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: playwright-results
|
|
||||||
path: |
|
|
||||||
packages/app/test-results/
|
|
||||||
packages/app/playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
relay-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build relay
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run relay tests
|
|
||||||
run: npm run test --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
cli-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Run CLI tests
|
|
||||||
run: npm run test --workspace=@getpaseo/cli
|
|
||||||
env:
|
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
|
||||||
PASEO_DICTATION_ENABLED: '0'
|
|
||||||
PASEO_VOICE_MODE_ENABLED: '0'
|
|
||||||
@@ -1,38 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.52 - 2026-04-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
|
||||||
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
|
||||||
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
|
||||||
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
|
||||||
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
|
||||||
- Initializing agents no longer show a loading spinner as if they're running.
|
|
||||||
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
|
||||||
- Error screens on desktop are now scrollable.
|
|
||||||
- Model list refreshes in the background when you open the model selector.
|
|
||||||
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
|
||||||
|
|
||||||
## 0.1.51 - 2026-04-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
|
||||||
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
|
||||||
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Model selector no longer crashes on iPad.
|
|
||||||
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
|
||||||
- OpenCode agents show the correct terminal state and refresh models reliably.
|
|
||||||
- Follow-up messages to agents that just finished a turn now work correctly.
|
|
||||||
- Commands now load properly for Pi agents.
|
|
||||||
- Internal debug output no longer appears in Claude agent timelines.
|
|
||||||
- QR scan screen cleaned up with simpler visuals.
|
|
||||||
|
|
||||||
## 0.1.50 - 2026-04-07
|
## 0.1.50 - 2026-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
-168
@@ -1,168 +0,0 @@
|
|||||||
# Contributing to Paseo
|
|
||||||
|
|
||||||
Thanks for taking the time to contribute.
|
|
||||||
|
|
||||||
## How this project works
|
|
||||||
|
|
||||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
|
||||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
|
||||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
|
||||||
|
|
||||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
|
||||||
|
|
||||||
## How to contribute
|
|
||||||
|
|
||||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
|
||||||
2. **Keep it small.** One bug, one flow, one focused change.
|
|
||||||
3. **Open a PR** once there is alignment on scope.
|
|
||||||
|
|
||||||
If you want to propose a direction change, start a conversation.
|
|
||||||
|
|
||||||
## Before you start
|
|
||||||
|
|
||||||
Please read these first:
|
|
||||||
|
|
||||||
- [README.md](README.md)
|
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
||||||
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
||||||
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
|
||||||
- [docs/TESTING.md](docs/TESTING.md)
|
|
||||||
- [CLAUDE.md](CLAUDE.md)
|
|
||||||
|
|
||||||
## What is most helpful
|
|
||||||
|
|
||||||
The most useful contributions right now are:
|
|
||||||
|
|
||||||
- bug fixes
|
|
||||||
- windows and linux specific fixes
|
|
||||||
- regression fixes
|
|
||||||
- doc improvements
|
|
||||||
- packaging / platform fixes
|
|
||||||
- focused UX improvements that fit the existing product direction
|
|
||||||
- tests that lock down important behavior
|
|
||||||
|
|
||||||
## Scope expectations
|
|
||||||
|
|
||||||
Please keep PRs narrow.
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
- fix one bug
|
|
||||||
- improve one flow
|
|
||||||
- add one focused panel or command
|
|
||||||
- tighten one piece of UI
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
- combine multiple product ideas in one PR
|
|
||||||
- bundle unrelated refactors with a feature
|
|
||||||
- sneak in roadmap decisions
|
|
||||||
|
|
||||||
If a contribution contains multiple ideas, split it up.
|
|
||||||
|
|
||||||
## Product fit matters
|
|
||||||
|
|
||||||
Paseo is an opinionated product.
|
|
||||||
|
|
||||||
When reviewing contributions, the bar is not just:
|
|
||||||
|
|
||||||
- is this useful?
|
|
||||||
- is this well implemented?
|
|
||||||
|
|
||||||
It is also:
|
|
||||||
|
|
||||||
- does this fit Paseo?
|
|
||||||
- does this add product surface that will be hard to maintain?
|
|
||||||
- does the value justify the maintenance surface it adds?
|
|
||||||
- does this solve a common need or over-serve an edge case?
|
|
||||||
- does this preserve the product's current direction?
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js matching `.tool-versions`
|
|
||||||
- npm workspaces
|
|
||||||
|
|
||||||
### Start local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# runs both daemon and expo app
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:server
|
|
||||||
npm run dev:app
|
|
||||||
npm run dev:desktop
|
|
||||||
npm run dev:website
|
|
||||||
npm run cli -- ls -a -g
|
|
||||||
```
|
|
||||||
|
|
||||||
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
|
||||||
|
|
||||||
## Multi-platform testing
|
|
||||||
|
|
||||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
|
||||||
|
|
||||||
Common checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
npm run test --workspaces --if-present
|
|
||||||
```
|
|
||||||
|
|
||||||
Important rules:
|
|
||||||
|
|
||||||
- always run `npm run typecheck` after changes
|
|
||||||
- tests should be deterministic
|
|
||||||
- prefer real dependencies over mocks when possible
|
|
||||||
- do not make breaking WebSocket / protocol changes
|
|
||||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
|
||||||
|
|
||||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
|
||||||
|
|
||||||
## Coding standards
|
|
||||||
|
|
||||||
Paseo has explicit standards. Follow them.
|
|
||||||
|
|
||||||
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
|
||||||
|
|
||||||
## PR checklist
|
|
||||||
|
|
||||||
Before opening a PR, make sure:
|
|
||||||
|
|
||||||
- there was prior discussion and alignment on scope (issue or conversation)
|
|
||||||
- the change is focused, one idea per PR
|
|
||||||
- the PR description explains what changed and why
|
|
||||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
|
||||||
- UI changes have been tested on mobile and web at minimum
|
|
||||||
- typecheck passes
|
|
||||||
- tests pass, or you clearly explain what could not be run
|
|
||||||
- relevant docs were updated if needed
|
|
||||||
|
|
||||||
## Communication
|
|
||||||
|
|
||||||
If you are unsure whether something fits, ask first.
|
|
||||||
|
|
||||||
That is especially true for:
|
|
||||||
|
|
||||||
- new core UX
|
|
||||||
- naming / terminology changes
|
|
||||||
- new extension points
|
|
||||||
- new orchestration models
|
|
||||||
- anything that would be hard to remove later
|
|
||||||
|
|
||||||
Early alignment saves everyone time.
|
|
||||||
|
|
||||||
## Forks are fine
|
|
||||||
|
|
||||||
If you want to explore a different product direction, a fork is completely fine.
|
|
||||||
|
|
||||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
# Data Model
|
|
||||||
|
|
||||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
|
||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory layout
|
|
||||||
|
|
||||||
```
|
|
||||||
$PASEO_HOME/
|
|
||||||
├── config.json # Daemon configuration
|
|
||||||
├── agents/
|
|
||||||
│ └── {project-dir}/
|
|
||||||
│ └── {agentId}.json # One file per agent
|
|
||||||
├── schedules/
|
|
||||||
│ └── {scheduleId}.json # One file per schedule
|
|
||||||
├── chat/
|
|
||||||
│ └── rooms.json # All rooms + messages
|
|
||||||
├── loops/
|
|
||||||
│ └── loops.json # All loop records
|
|
||||||
├── projects/
|
|
||||||
│ ├── projects.json # Project registry
|
|
||||||
│ └── workspaces.json # Workspace registry
|
|
||||||
└── push-tokens.json # Expo push notification tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Agent Record
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
|
||||||
|
|
||||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | UUID, primary key |
|
|
||||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
|
||||||
| `cwd` | `string` | Working directory the agent operates in |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
|
||||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
|
||||||
| `title` | `string?` | User-visible title |
|
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
|
||||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
|
||||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
|
||||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
|
||||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
|
||||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
|
||||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
|
||||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
### Nested: SerializableConfig
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `title` | `string?` | Configured title |
|
|
||||||
| `modeId` | `string?` | Configured mode |
|
|
||||||
| `model` | `string?` | Configured model |
|
|
||||||
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
|
||||||
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
|
||||||
| `extra` | `Record<string, any>?` | Provider-specific config |
|
|
||||||
| `systemPrompt` | `string?` | Custom system prompt |
|
|
||||||
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
|
||||||
|
|
||||||
### Nested: RuntimeInfo
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `provider` | `string` | Active provider |
|
|
||||||
| `sessionId` | `string?` | Active session ID |
|
|
||||||
| `model` | `string?` | Active model |
|
|
||||||
| `thinkingOptionId` | `string?` | Active thinking option |
|
|
||||||
| `modeId` | `string?` | Active mode |
|
|
||||||
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
|
||||||
|
|
||||||
### Nested: PersistenceHandle
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `provider` | `string` | Provider that owns the session |
|
|
||||||
| `sessionId` | `string` | Session ID for resumption |
|
|
||||||
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
|
||||||
| `metadata` | `Record<string, any>?` | Extra metadata |
|
|
||||||
|
|
||||||
### Nested: AgentFeature (discriminated union on `type`)
|
|
||||||
|
|
||||||
**Toggle:**
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `type` | `"toggle"` |
|
|
||||||
| `id` | `string` |
|
|
||||||
| `label` | `string` |
|
|
||||||
| `description` | `string?` |
|
|
||||||
| `tooltip` | `string?` |
|
|
||||||
| `icon` | `string?` |
|
|
||||||
| `value` | `boolean` |
|
|
||||||
|
|
||||||
**Select:**
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `type` | `"select"` |
|
|
||||||
| `id` | `string` |
|
|
||||||
| `label` | `string` |
|
|
||||||
| `description` | `string?` |
|
|
||||||
| `tooltip` | `string?` |
|
|
||||||
| `icon` | `string?` |
|
|
||||||
| `value` | `string?` |
|
|
||||||
| `options` | `AgentSelectOption[]` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Daemon Configuration
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/config.json`
|
|
||||||
|
|
||||||
Single file, validated with `PersistedConfigSchema`.
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
daemon: {
|
|
||||||
listen: "127.0.0.1:6767",
|
|
||||||
allowedHosts: true | string[],
|
|
||||||
mcp: { enabled: boolean },
|
|
||||||
cors: { allowedOrigins: string[] },
|
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
baseUrl: string
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
openai: { apiKey: string },
|
|
||||||
local: { modelsDir: string }
|
|
||||||
},
|
|
||||||
agents: {
|
|
||||||
providers: {
|
|
||||||
[provider: string]: {
|
|
||||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
|
||||||
env: Record<string, string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
|
||||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
|
||||||
},
|
|
||||||
log: {
|
|
||||||
level, format,
|
|
||||||
console: { level, format },
|
|
||||||
file: { level, path, rotate: { maxSize, maxFiles } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional with sensible defaults.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Schedule
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
|
||||||
|
|
||||||
One file per schedule. ID is 8 hex characters.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | 8-char hex ID |
|
|
||||||
| `name` | `string?` | Human-readable name |
|
|
||||||
| `prompt` | `string` | The prompt to send |
|
|
||||||
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
|
||||||
| `target` | `ScheduleTarget` | What to run (see below) |
|
|
||||||
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
|
||||||
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
|
||||||
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
|
||||||
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
|
||||||
| `maxRuns` | `number?` | Max executions before completing |
|
|
||||||
| `runs` | `ScheduleRun[]` | Execution history |
|
|
||||||
|
|
||||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
|
||||||
|
|
||||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
|
||||||
- `{ type: "cron", expression: string }` — cron expression
|
|
||||||
|
|
||||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
|
||||||
|
|
||||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
|
||||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
|
||||||
|
|
||||||
### Nested: ScheduleRun
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | Run ID |
|
|
||||||
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
|
||||||
| `endedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
|
||||||
| `agentId` | `string?` (UUID) | Agent used for this run |
|
|
||||||
| `output` | `string?` | Agent output text |
|
|
||||||
| `error` | `string?` | Error message if failed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Chat
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
|
||||||
|
|
||||||
Single file containing all rooms and messages.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rooms": [ ... ],
|
|
||||||
"messages": [ ... ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ChatRoom
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` (UUID) | |
|
|
||||||
| `name` | `string` | Unique room name (case-insensitive) |
|
|
||||||
| `purpose` | `string?` | Room description |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
|
||||||
|
|
||||||
### ChatMessage
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` (UUID) | |
|
|
||||||
| `roomId` | `string` | FK to ChatRoom.id |
|
|
||||||
| `authorAgentId` | `string` | Agent ID of the author |
|
|
||||||
| `body` | `string` | Message text (supports `@mentions`) |
|
|
||||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
|
||||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Loop
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
|
||||||
|
|
||||||
Single file containing an array of all loop records.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | 8-char UUID prefix |
|
|
||||||
| `name` | `string?` | Human-readable name |
|
|
||||||
| `prompt` | `string` | Worker prompt |
|
|
||||||
| `cwd` | `string` | Working directory |
|
|
||||||
| `provider` | `string` | Default provider |
|
|
||||||
| `model` | `string?` | Default model |
|
|
||||||
| `workerProvider` | `string?` | Override provider for workers |
|
|
||||||
| `workerModel` | `string?` | Override model for workers |
|
|
||||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
|
||||||
| `verifierModel` | `string?` | Override model for verifiers |
|
|
||||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
|
||||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
|
||||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
|
||||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
|
||||||
| `maxIterations` | `number?` | Cap on iterations |
|
|
||||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
|
||||||
| `completedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `iterations` | `LoopIteration[]` | |
|
|
||||||
| `logs` | `LoopLogEntry[]` | |
|
|
||||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
|
||||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
|
||||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
|
||||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
|
||||||
|
|
||||||
### Nested: LoopIteration
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `index` | `number` | 1-based iteration index |
|
|
||||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
|
||||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
|
||||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
|
||||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
|
||||||
| `failureReason` | `string?` | |
|
|
||||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
|
||||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
|
||||||
|
|
||||||
### Nested: LoopLogEntry
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `seq` | `number` (monotonic) |
|
|
||||||
| `timestamp` | `string` (ISO 8601) |
|
|
||||||
| `iteration` | `number?` |
|
|
||||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
|
||||||
| `level` | `"info" \| "error"` |
|
|
||||||
| `text` | `string` |
|
|
||||||
|
|
||||||
### Nested: LoopVerifyCheckResult
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `command` | `string` |
|
|
||||||
| `exitCode` | `number` |
|
|
||||||
| `passed` | `boolean` |
|
|
||||||
| `stdout` | `string` |
|
|
||||||
| `stderr` | `string` |
|
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
|
||||||
|
|
||||||
### Nested: LoopVerifyPromptResult
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `passed` | `boolean` |
|
|
||||||
| `reason` | `string` |
|
|
||||||
| `verifierAgentId` | `string?` |
|
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Project Registry
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
|
||||||
|
|
||||||
Array of project records.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `projectId` | `string` | Primary key |
|
|
||||||
| `rootPath` | `string` | Filesystem root of the project |
|
|
||||||
| `kind` | `"git" \| "non_git"` | |
|
|
||||||
| `displayName` | `string` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Workspace Registry
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
|
||||||
|
|
||||||
Array of workspace records. A workspace is a specific working directory within a project.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `workspaceId` | `string` | Primary key |
|
|
||||||
| `projectId` | `string` | FK to Project.projectId |
|
|
||||||
| `cwd` | `string` | Filesystem path |
|
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
|
||||||
| `displayName` | `string` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Push Token Store
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tokens": ["ExponentPushToken[...]", ...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client-side stores (App)
|
|
||||||
|
|
||||||
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
|
||||||
|
|
||||||
### Draft Store
|
|
||||||
|
|
||||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
drafts: Record<draftKey, {
|
|
||||||
input: { text: string, images: AttachmentMetadata[] },
|
|
||||||
lifecycle: "active" | "abandoned" | "sent",
|
|
||||||
updatedAt: number, // epoch ms
|
|
||||||
version: number // optimistic concurrency
|
|
||||||
}>,
|
|
||||||
createModalDraft: DraftRecord | null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attachment Store (Web)
|
|
||||||
|
|
||||||
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
|
||||||
|
|
||||||
Stores binary attachment blobs keyed by attachment ID.
|
|
||||||
|
|
||||||
### AttachmentMetadata
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | Unique attachment ID |
|
|
||||||
| `mimeType` | `string` | MIME type |
|
|
||||||
| `storageType` | `string` | Storage backend identifier |
|
|
||||||
| `storageKey` | `string` | Key within the storage backend |
|
|
||||||
| `createdAt` | `number` | Epoch ms |
|
|
||||||
| `fileName` | `string?` | Original filename |
|
|
||||||
| `byteSize` | `number?` | Size in bytes |
|
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M=";
|
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -34906,16 +34906,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.52",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -35032,11 +35032,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.52",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35077,11 +35077,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.52",
|
"@getpaseo/cli": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35115,7 +35115,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35316,7 +35316,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35342,7 +35342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35358,14 +35358,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.52",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/relay": "0.1.52",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35764,7 +35764,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.52",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { THEME_TO_UNISTYLES } from "@/styles/theme";
|
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -58,7 +57,7 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
@@ -373,7 +372,7 @@ function AppContainer({
|
|||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -562,7 +561,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
UnistylesRuntime.setTheme(settings.theme);
|
||||||
}
|
}
|
||||||
}, [settingsLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
@@ -59,7 +61,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderColor: theme.colors.accent,
|
borderColor: theme.colors.palette.blue[400],
|
||||||
},
|
},
|
||||||
cornerTL: {
|
cornerTL: {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -146,11 +148,28 @@ export default function PairScanScreen() {
|
|||||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return (
|
||||||
|
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||||
|
pendingNameHost.hostname ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pendingNameHost],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -205,6 +224,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
|
if (pendingNameHost) return;
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
@@ -228,7 +248,7 @@ export default function PairScanScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client, hostname } = await connectToDaemon(
|
const { client } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -239,7 +259,13 @@ export default function PairScanScreen() {
|
|||||||
);
|
);
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||||
|
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||||
|
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
returnToSource(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -250,7 +276,7 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
@@ -281,6 +307,25 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{pendingNameHost ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
}}
|
||||||
|
onSave={(label) => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
void renameHost(serverId, label).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
<Pressable onPress={closeToSource}>
|
<Pressable onPress={closeToSource}>
|
||||||
@@ -314,6 +359,7 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||||
{isPairing ? (
|
{isPairing ? (
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
Pairing…
|
Pairing…
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
@@ -120,7 +119,7 @@ export function AdaptiveModalSheet({
|
|||||||
testID,
|
testID,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
@@ -240,7 +239,7 @@ export function AdaptiveModalSheet({
|
|||||||
*/
|
*/
|
||||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||||
function AdaptiveTextInput(props, ref) {
|
function AdaptiveTextInput(props, ref) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Link2 } from "lucide-react-native";
|
import { Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -152,7 +151,7 @@ export function AddHostModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
@@ -219,7 +218,6 @@ export function AddHostModal({
|
|||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint,
|
||||||
label: hostname ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
@@ -49,7 +48,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,8 +131,6 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings();
|
|
||||||
|
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
@@ -155,8 +151,10 @@ export function AgentInputArea({
|
|||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isDesktopWebBreakpoint =
|
||||||
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
|
Platform.OS === "web" &&
|
||||||
|
UnistylesRuntime.breakpoint !== "xs" &&
|
||||||
|
UnistylesRuntime.breakpoint !== "sm";
|
||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
@@ -744,7 +742,6 @@ export function AgentInputArea({
|
|||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
defaultSendBehavior={appSettings.sendBehavior}
|
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
@@ -215,7 +214,7 @@ export function AgentList({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ type ControlledAgentStatusBarProps = {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
onModelSelectorOpen?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -111,7 +110,6 @@ export interface DraftAgentStatusBarProps {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
onModelSelectorOpen?: () => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +217,6 @@ function ControlledStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -414,7 +411,6 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onOpen={onModelSelectorOpen}
|
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -666,7 +662,6 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onOpen={onModelSelectorOpen}
|
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
renderTrigger={({ selectedModelLabel }) => (
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
<View
|
<View
|
||||||
@@ -880,7 +875,6 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
invalidate: invalidateSnapshot,
|
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId);
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotModels = useMemo(() => {
|
||||||
@@ -968,14 +962,13 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void updatePreferences(
|
void updatePreferences(
|
||||||
(current) =>
|
mergeProviderPreferences({
|
||||||
mergeProviderPreferences({
|
preferences,
|
||||||
preferences: current,
|
provider: agent.provider,
|
||||||
provider: agent.provider,
|
updates: {
|
||||||
updates: {
|
model: modelId,
|
||||||
model: modelId,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] persist model preference failed", error);
|
console.warn("[AgentStatusBar] persist model preference failed", error);
|
||||||
});
|
});
|
||||||
@@ -985,7 +978,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -998,17 +991,16 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
const activeModelId = modelSelection.activeModelId;
|
const activeModelId = modelSelection.activeModelId;
|
||||||
if (activeModelId) {
|
if (activeModelId) {
|
||||||
void updatePreferences(
|
void updatePreferences(
|
||||||
(current) =>
|
mergeProviderPreferences({
|
||||||
mergeProviderPreferences({
|
preferences,
|
||||||
preferences: current,
|
provider: agent.provider,
|
||||||
provider: agent.provider,
|
updates: {
|
||||||
updates: {
|
model: activeModelId,
|
||||||
model: activeModelId,
|
thinkingByModel: {
|
||||||
thinkingByModel: {
|
[activeModelId]: thinkingOptionId,
|
||||||
[activeModelId]: thinkingOptionId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] persist thinking preference failed", error);
|
console.warn("[AgentStatusBar] persist thinking preference failed", error);
|
||||||
});
|
});
|
||||||
@@ -1022,26 +1014,11 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void updatePreferences(
|
|
||||||
(current) =>
|
|
||||||
mergeProviderPreferences({
|
|
||||||
preferences: current,
|
|
||||||
provider: agent.provider,
|
|
||||||
updates: {
|
|
||||||
featureValues: {
|
|
||||||
[featureId]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[AgentStatusBar] persist feature preference failed", error);
|
|
||||||
});
|
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||||
onModelSelectorOpen={invalidateSnapshot}
|
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1068,7 +1045,6 @@ export function DraftAgentStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -1107,13 +1083,12 @@ export function DraftAgentStatusBar({
|
|||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={(provider, modelId) => {
|
onToggleFavorite={(provider, modelId) => {
|
||||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onOpen={onModelSelectorOpen}
|
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
@@ -1154,7 +1129,7 @@ export function DraftAgentStatusBar({
|
|||||||
isModelLoading={isAllModelsLoading}
|
isModelLoading={isAllModelsLoading}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1163,7 +1138,6 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
features={features}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
onModelSelectorOpen={onModelSelectorOpen}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -110,7 +109,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveStreamRenderStrategy({
|
resolveStreamRenderStrategy({
|
||||||
@@ -708,7 +707,7 @@ function PermissionRequestCard({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const { request } = permission;
|
const { request } = permission;
|
||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { Pressable, Text, View } from "react-native";
|
|
||||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
|
|
||||||
interface BranchSwitcherProps {
|
|
||||||
currentBranchName: string | null;
|
|
||||||
title: string;
|
|
||||||
branchOptions: ComboboxOption[];
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onBranchSelect: (branchId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BranchSwitcher({
|
|
||||||
currentBranchName,
|
|
||||||
title,
|
|
||||||
branchOptions,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onBranchSelect,
|
|
||||||
}: BranchSwitcherProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const anchorRef = useRef<View>(null);
|
|
||||||
|
|
||||||
if (!currentBranchName) {
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
testID="workspace-header-title"
|
|
||||||
style={styles.headerTitle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View ref={anchorRef} collapsable={false}>
|
|
||||||
<Pressable
|
|
||||||
testID="workspace-header-branch-switcher"
|
|
||||||
onPress={() => onOpenChange(true)}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.branchSwitcherTrigger,
|
|
||||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
|
||||||
>
|
|
||||||
<GitBranch
|
|
||||||
size={14}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
testID="workspace-header-title"
|
|
||||||
style={styles.headerTitle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown
|
|
||||||
size={12}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Combobox
|
|
||||||
options={branchOptions}
|
|
||||||
value={currentBranchName}
|
|
||||||
onSelect={onBranchSelect}
|
|
||||||
searchable
|
|
||||||
placeholder="Switch branch..."
|
|
||||||
searchPlaceholder="Filter branches..."
|
|
||||||
emptyText="No branches found."
|
|
||||||
title="Switch branch"
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
desktopPlacement="bottom-start"
|
|
||||||
desktopPreventInitialFlash
|
|
||||||
desktopMinWidth={280}
|
|
||||||
renderOption={({ option, selected, active, onPress }) => (
|
|
||||||
<ComboboxItem
|
|
||||||
key={option.id}
|
|
||||||
label={option.label}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
onPress={onPress}
|
|
||||||
leadingSlot={
|
|
||||||
<GitBranch
|
|
||||||
size={14}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
branchSwitcherTrigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
branchSwitcherTriggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -59,7 +58,6 @@ interface CombinedModelSelectorProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
onOpen?: () => void;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -350,8 +348,7 @@ function ProviderSearchInput({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||||
@@ -518,7 +515,6 @@ export function CombinedModelSelector({
|
|||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
onOpen,
|
|
||||||
onClose,
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
@@ -543,14 +539,12 @@ export function CombinedModelSelector({
|
|||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
if (open) {
|
if (!open) {
|
||||||
onOpen?.();
|
|
||||||
} else {
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onOpen, onClose, singleProviderView],
|
[onClose, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
@@ -48,7 +48,7 @@ export function ExplorerSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -90,7 +89,7 @@ export function FileExplorerPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -849,7 +848,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
indentGuide: {
|
indentGuide: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
@@ -242,7 +241,7 @@ export function FilePane({
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
TextStyle,
|
TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import {
|
import {
|
||||||
AlignJustify,
|
AlignJustify,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -57,12 +56,7 @@ import {
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||||
import {
|
import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
|
||||||
buildSplitDiffRows,
|
|
||||||
buildUnifiedDiffLines,
|
|
||||||
type SplitDiffDisplayLine,
|
|
||||||
type SplitDiffRow,
|
|
||||||
} from "@/utils/diff-layout";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -80,11 +74,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import {
|
|
||||||
formatDiffContentText,
|
|
||||||
formatDiffGutterText,
|
|
||||||
hasVisibleDiffTokens,
|
|
||||||
} from "@/utils/diff-rendering";
|
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -175,7 +164,7 @@ function DiffGutterCell({
|
|||||||
type === "remove" && styles.removeLineNumberText,
|
type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffGutterText(lineNumber)}
|
{lineNumber != null ? String(lineNumber) : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -188,12 +177,10 @@ function DiffTextLine({
|
|||||||
line: DiffLine;
|
line: DiffLine;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
|
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
|
||||||
{line.type !== "header" && visibleTokens ? (
|
{line.tokens && line.type !== "header" ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -205,7 +192,7 @@ function DiffTextLine({
|
|||||||
line.type === "context" && styles.contextLineText,
|
line.type === "context" && styles.contextLineText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffContentText(line.content)}
|
{line.content || " "}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -219,12 +206,10 @@ function SplitTextLine({
|
|||||||
line: SplitDiffDisplayLine | null;
|
line: SplitDiffDisplayLine | null;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
|
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
|
||||||
{visibleTokens ? (
|
{line?.tokens ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -236,7 +221,7 @@ function SplitTextLine({
|
|||||||
!line && styles.emptySplitCellText,
|
!line && styles.emptySplitCellText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffContentText(line?.content)}
|
{line?.content ?? ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -254,8 +239,6 @@ function DiffLineView({
|
|||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -271,11 +254,11 @@ function DiffLineView({
|
|||||||
line.type === "remove" && styles.removeLineNumberText,
|
line.type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffGutterText(lineNumber)}
|
{lineNumber != null ? String(lineNumber) : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{line.type !== "header" && visibleTokens ? (
|
{line.tokens && line.type !== "header" ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -287,7 +270,7 @@ function DiffLineView({
|
|||||||
line.type === "context" && styles.contextLineText,
|
line.type === "context" && styles.contextLineText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffContentText(line.content)}
|
{line.content || " "}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -303,8 +286,6 @@ function SplitDiffLine({
|
|||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -320,11 +301,11 @@ function SplitDiffLine({
|
|||||||
line?.type === "remove" && styles.removeLineNumberText,
|
line?.type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffGutterText(line?.lineNumber ?? null)}
|
{line?.lineNumber != null ? String(line.lineNumber) : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{visibleTokens ? (
|
{line?.tokens ? (
|
||||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -336,7 +317,7 @@ function SplitDiffLine({
|
|||||||
!line && styles.emptySplitCellText,
|
!line && styles.emptySplitCellText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDiffContentText(line?.content)}
|
{line?.content ?? ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -553,7 +534,26 @@ function DiffFileBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const computedLines = buildUnifiedDiffLines(file);
|
const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = [];
|
||||||
|
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
||||||
|
let oldLineNo = hunk.oldStart;
|
||||||
|
let newLineNo = hunk.newStart;
|
||||||
|
for (const [lineIndex, line] of hunk.lines.entries()) {
|
||||||
|
let lineNumber: number | null = null;
|
||||||
|
if (line.type === "remove") {
|
||||||
|
lineNumber = oldLineNo;
|
||||||
|
oldLineNo++;
|
||||||
|
} else if (line.type === "add") {
|
||||||
|
lineNumber = newLineNo;
|
||||||
|
newLineNo++;
|
||||||
|
} else if (line.type === "context") {
|
||||||
|
lineNumber = newLineNo;
|
||||||
|
oldLineNo++;
|
||||||
|
newLineNo++;
|
||||||
|
}
|
||||||
|
computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (wrapLines) {
|
if (wrapLines) {
|
||||||
return (
|
return (
|
||||||
@@ -607,7 +607,7 @@ type DiffFlatItem =
|
|||||||
|
|
||||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -1724,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
newBadgeText: {
|
newBadgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
deletedBadge: {
|
deletedBadge: {
|
||||||
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
||||||
@@ -1736,17 +1736,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
deletedBadgeText: {
|
deletedBadgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.palette.red[500],
|
||||||
},
|
},
|
||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
deletions: {
|
deletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.palette.red[500],
|
||||||
},
|
},
|
||||||
diffContent: {
|
diffContent: {
|
||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
@@ -1824,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
addLineNumberText: {
|
addLineNumberText: {
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
removeLineNumberText: {
|
removeLineNumberText: {
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.palette.red[500],
|
||||||
},
|
},
|
||||||
diffLineText: {
|
diffLineText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
@@ -44,7 +44,7 @@ export function SidebarMenuToggle({
|
|||||||
nativeID = "menu-button",
|
nativeID = "menu-button",
|
||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
useIsCompactFormFactor,
|
isCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
@@ -26,7 +26,7 @@ interface ScreenHeaderProps {
|
|||||||
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const padding = useWindowControlsPadding("header");
|
const padding = useWindowControlsPadding("header");
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
|||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
useIsCompactFormFactor,
|
isCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
@@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
newAgentButtonHovered: {
|
newAgentButtonHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
newAgentButtonActive: {
|
newAgentButtonActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
hostTriggerHovered: {
|
hostTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
hostStatusDot: {
|
hostStatusDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
|
|||||||
@@ -87,9 +87,6 @@ export interface MessageInputProps {
|
|||||||
voiceAgentId?: string;
|
voiceAgentId?: string;
|
||||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||||
isAgentRunning?: boolean;
|
isAgentRunning?: boolean;
|
||||||
/** Controls what the default send action (Enter, send button, dictation) does
|
|
||||||
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
|
|
||||||
defaultSendBehavior?: "interrupt" | "queue";
|
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
/** Optional handler used when submit button is in loading state. */
|
/** Optional handler used when submit button is in loading state. */
|
||||||
@@ -211,7 +208,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
voiceServerId,
|
voiceServerId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
defaultSendBehavior = "interrupt",
|
|
||||||
onQueue,
|
onQueue,
|
||||||
onSubmitLoadingPress,
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
@@ -356,17 +352,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
if (shouldAutoSend) {
|
if (shouldAutoSend) {
|
||||||
const imageAttachments = images.length > 0 ? images : undefined;
|
const imageAttachments = images.length > 0 ? images : undefined;
|
||||||
// Respect send behavior setting: when "queue", dictation queues too.
|
onSubmit({
|
||||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
text: nextValue,
|
||||||
onQueue({ text: nextValue, images: imageAttachments });
|
images: imageAttachments,
|
||||||
onChangeText("");
|
forceSend: isAgentRunning || undefined,
|
||||||
} else {
|
});
|
||||||
onSubmit({
|
|
||||||
text: nextValue,
|
|
||||||
images: imageAttachments,
|
|
||||||
forceSend: isAgentRunning || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
@@ -377,7 +367,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
[onChangeText, onSubmit, images, isAgentRunning],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback(
|
const handleDictationError = useCallback(
|
||||||
@@ -588,26 +578,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
||||||
|
|
||||||
// Default send action: respects the sendBehavior setting.
|
|
||||||
// When "interrupt" (default), primary action sends immediately (interrupts).
|
|
||||||
// When "queue", primary action queues when agent is running.
|
|
||||||
const handleDefaultSendAction = useCallback(() => {
|
|
||||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
|
||||||
handleQueueMessage();
|
|
||||||
} else {
|
|
||||||
handleSendMessage();
|
|
||||||
}
|
|
||||||
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
|
|
||||||
|
|
||||||
// Alternate send action: always the opposite of the default.
|
|
||||||
const handleAlternateSendAction = useCallback(() => {
|
|
||||||
if (defaultSendBehavior === "queue") {
|
|
||||||
handleSendMessage(); // interrupt
|
|
||||||
} else if (onQueue) {
|
|
||||||
handleQueueMessage(); // queue
|
|
||||||
}
|
|
||||||
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
|
|
||||||
|
|
||||||
// Web input height measurement
|
// Web input height measurement
|
||||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||||
@@ -902,18 +872,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||||
if (shiftKey) return;
|
if (shiftKey) return;
|
||||||
|
|
||||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
|
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
|
||||||
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
||||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleAlternateSendAction();
|
handleQueueMessage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter: default send action (interrupt or queue, based on setting)
|
// Enter: send (interrupts agent if running)
|
||||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleDefaultSendAction();
|
handleSendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
@@ -922,14 +892,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
|
||||||
const submitAccessibilityLabel = canPressLoadingButton
|
const submitAccessibilityLabel = canPressLoadingButton
|
||||||
? "Interrupt agent"
|
? "Interrupt agent"
|
||||||
: defaultActionQueues
|
: isAgentRunning
|
||||||
? "Queue message"
|
? "Send and interrupt"
|
||||||
: isAgentRunning
|
: "Send message";
|
||||||
? "Send and interrupt"
|
|
||||||
: "Send message";
|
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
@@ -1104,10 +1071,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
{hasSendableContent && isAgentRunning && onQueue && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleAlternateSendAction}
|
onPress={handleQueueMessage}
|
||||||
disabled={!isConnected || disabled}
|
disabled={!isConnected || disabled}
|
||||||
accessibilityLabel="Queue message"
|
accessibilityLabel="Queue message"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1132,7 +1099,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
|
||||||
disabled={isSendButtonDisabled}
|
disabled={isSendButtonDisabled}
|
||||||
accessibilityLabel={submitAccessibilityLabel}
|
accessibilityLabel={submitAccessibilityLabel}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1146,7 +1113,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
|
<Text style={styles.tooltipText}>Send</Text>
|
||||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
@@ -1823,7 +1822,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
helper: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
marginTop: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface NameHostModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
onSkip: () => void;
|
||||||
|
onSave: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const hasEditedRef = useRef(false);
|
||||||
|
|
||||||
|
const suggested = (hostname?.trim() || serverId).trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setLabel(suggested);
|
||||||
|
hasEditedRef.current = false;
|
||||||
|
}, [suggested, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (hasEditedRef.current) return;
|
||||||
|
if (!hostname) return;
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed === serverId) {
|
||||||
|
setLabel(hostname.trim());
|
||||||
|
}
|
||||||
|
}, [hostname, label, serverId, visible]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string) => {
|
||||||
|
hasEditedRef.current = true;
|
||||||
|
setLabel(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onSkip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(trimmed);
|
||||||
|
}, [label, onSave, onSkip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
title="Name this host"
|
||||||
|
visible={visible}
|
||||||
|
onClose={onSkip}
|
||||||
|
testID="name-host-modal"
|
||||||
|
>
|
||||||
|
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Label</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
value={label}
|
||||||
|
onChangeText={handleChange}
|
||||||
|
placeholder={suggested}
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSave}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -67,7 +66,7 @@ export function PairLinkModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const [offerUrl, setOfferUrl] = useState("");
|
const [offerUrl, setOfferUrl] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -147,7 +146,7 @@ export function PairLinkModal({
|
|||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -64,7 +63,7 @@ const IS_WEB = Platform.OS === "web";
|
|||||||
|
|
||||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const questions = parseQuestions(permission.request.input);
|
const questions = parseQuestions(permission.request.input);
|
||||||
|
|
||||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
@@ -713,7 +713,7 @@ function ProjectHeaderRow({
|
|||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
const isMobileBreakpoint = isCompactFormFactor();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -1606,7 +1606,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const isNative = Platform.OS !== "web";
|
const isNative = Platform.OS !== "web";
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||||
@@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
projectRowHovered: {
|
projectRowHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
projectRowPressed: {
|
projectRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
projectActionButtonHovered: {
|
projectActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
projectActionButtonText: {
|
projectActionButtonText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
projectIconActionButtonHovered: {
|
projectIconActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
projectIconActionButtonHidden: {
|
projectIconActionButtonHidden: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
@@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
workspaceRowHovered: {
|
workspaceRowHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
workspaceRowPressed: {
|
workspaceRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
...theme.shadow.md,
|
...theme.shadow.md,
|
||||||
},
|
},
|
||||||
sidebarRowSelected: {
|
sidebarRowSelected: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
workspaceRowContainer: {
|
workspaceRowContainer: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -2241,12 +2241,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffStatAdditions: {
|
diffStatAdditions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
diffStatDeletions: {
|
diffStatDeletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.palette.red[500],
|
||||||
},
|
},
|
||||||
kebabButton: {
|
kebabButton: {
|
||||||
padding: 2,
|
padding: 2,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -92,7 +92,7 @@ export function TerminalPane({
|
|||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
@@ -109,7 +108,6 @@ export function ToastViewport({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(-8)).current;
|
const translateY = useRef(new Animated.Value(-8)).current;
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -183,6 +181,7 @@ export function ToastViewport({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const topOffset =
|
const topOffset =
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildVisibleComboboxOptions,
|
buildVisibleComboboxOptions,
|
||||||
filterAndRankComboboxOptions,
|
|
||||||
getComboboxFallbackIndex,
|
getComboboxFallbackIndex,
|
||||||
orderVisibleComboboxOptions,
|
orderVisibleComboboxOptions,
|
||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
@@ -48,48 +47,6 @@ describe("buildVisibleComboboxOptions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("filterAndRankComboboxOptions", () => {
|
|
||||||
const options = [
|
|
||||||
{ id: "feat/login", label: "feat/login" },
|
|
||||||
{ id: "main", label: "main" },
|
|
||||||
{ id: "feat/main-nav", label: "feat/main-nav" },
|
|
||||||
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("returns all options when search is empty", () => {
|
|
||||||
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by label substring", () => {
|
|
||||||
const result = filterAndRankComboboxOptions(options, "login");
|
|
||||||
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by id substring", () => {
|
|
||||||
const result = filterAndRankComboboxOptions(options, "fix/");
|
|
||||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by description substring", () => {
|
|
||||||
const result = filterAndRankComboboxOptions(options, "logout bug");
|
|
||||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ranks prefix matches above substring matches", () => {
|
|
||||||
const result = filterAndRankComboboxOptions(options, "main");
|
|
||||||
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive", () => {
|
|
||||||
const items = [{ id: "Alpha", label: "Alpha" }];
|
|
||||||
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty when nothing matches", () => {
|
|
||||||
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("combobox above-search ordering", () => {
|
describe("combobox above-search ordering", () => {
|
||||||
const visible = [
|
const visible = [
|
||||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||||
|
|||||||
@@ -35,33 +35,18 @@ export function shouldShowCustomComboboxOption(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterAndRankComboboxOptions(
|
|
||||||
options: ComboboxOptionModel[],
|
|
||||||
search: string,
|
|
||||||
): ComboboxOptionModel[] {
|
|
||||||
if (!search) return options;
|
|
||||||
return options
|
|
||||||
.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.label.toLowerCase().includes(search) ||
|
|
||||||
opt.id.toLowerCase().includes(search) ||
|
|
||||||
opt.description?.toLowerCase().includes(search),
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aPrefix =
|
|
||||||
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
|
||||||
const bPrefix =
|
|
||||||
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
|
||||||
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVisibleComboboxOptions(
|
export function buildVisibleComboboxOptions(
|
||||||
input: BuildVisibleComboboxOptionsInput,
|
input: BuildVisibleComboboxOptionsInput,
|
||||||
): ComboboxOptionModel[] {
|
): ComboboxOptionModel[] {
|
||||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||||
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
const filteredOptions = normalizedSearch
|
||||||
|
? input.options.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
opt.description?.toLowerCase().includes(normalizedSearch),
|
||||||
|
)
|
||||||
|
: input.options;
|
||||||
|
|
||||||
const sanitizedSearchValue = input.searchQuery.trim();
|
const sanitizedSearchValue = input.searchQuery.trim();
|
||||||
const showCustomOption = shouldShowCustomComboboxOption({
|
const showCustomOption = shouldShowCustomComboboxOption({
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
@@ -262,7 +261,7 @@ export function Combobox({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||||
const isDesktopAboveSearch =
|
const isDesktopAboveSearch =
|
||||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||||
@@ -660,7 +659,13 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultContent = optionsList;
|
const defaultContent = (
|
||||||
|
<>
|
||||||
|
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||||
|
{searchable ? searchInput : null}
|
||||||
|
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const content = children ?? defaultContent;
|
const content = children ?? defaultContent;
|
||||||
|
|
||||||
@@ -685,7 +690,6 @@ export function Combobox({
|
|||||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
{stickyHeader}
|
{stickyHeader}
|
||||||
{!children && searchable ? searchInput : null}
|
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={styles.comboboxScrollContent}
|
contentContainerStyle={styles.comboboxScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -741,7 +745,6 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{searchable ? searchInput : null}
|
|
||||||
{effectiveOptionsPosition === "above-search" ? (
|
{effectiveOptionsPosition === "above-search" ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={desktopOptionsScrollRef}
|
ref={desktopOptionsScrollRef}
|
||||||
@@ -756,7 +759,9 @@ export function Combobox({
|
|||||||
>
|
>
|
||||||
{optionsList}
|
{optionsList}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : null}
|
||||||
|
{searchable ? searchInput : null}
|
||||||
|
{effectiveOptionsPosition === "below-search" ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
contentContainerStyle={styles.desktopScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -765,7 +770,7 @@ export function Combobox({
|
|||||||
>
|
>
|
||||||
{optionsList}
|
{optionsList}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -778,12 +783,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
searchInputContainer: {
|
searchInputContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: theme.spacing[3],
|
borderWidth: 1,
|
||||||
gap: theme.spacing[2],
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderBottomWidth: 1,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderBottomColor: theme.colors.border,
|
paddingHorizontal: theme.spacing[3],
|
||||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
marginHorizontal: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -348,7 +347,7 @@ export function ContextMenuContent({
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const context = useContextMenuContext("ContextMenuContent");
|
const context = useContextMenuContext("ContextMenuContent");
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
|
|||||||
@@ -551,9 +551,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
backgroundColor: theme.colors.popover,
|
backgroundColor: theme.colors.popover,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[2],
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.border,
|
||||||
...theme.shadow.md,
|
...theme.shadow.sm,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import type { HostProfile } from "@/types/host-connection";
|
|||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeConnected,
|
isHostRuntimeConnected,
|
||||||
|
useHostMutations,
|
||||||
useHostRuntimeSnapshot,
|
useHostRuntimeSnapshot,
|
||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
|
import { NameHostModal } from "./name-host-modal";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
@@ -234,19 +237,41 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { renameHost } = useHostMutations();
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return (
|
||||||
|
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||||
|
pendingNameHost.hostname ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pendingNameHost],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anyOnlineServerId) {
|
if (!anyOnlineServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pendingNameHost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||||
}, [anyOnlineServerId, router]);
|
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -371,8 +396,13 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<AddHostModal
|
<AddHostModal
|
||||||
visible={isDirectOpen}
|
visible={isDirectOpen}
|
||||||
onClose={() => setIsDirectOpen(false)}
|
onClose={() => setIsDirectOpen(false)}
|
||||||
onSaved={({ profile, serverId }) => {
|
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
|
setPendingRedirectServerId(serverId);
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -380,11 +410,38 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={isPasteLinkOpen}
|
visible={isPasteLinkOpen}
|
||||||
onClose={() => setIsPasteLinkOpen(false)}
|
onClose={() => setIsPasteLinkOpen(false)}
|
||||||
onSaved={({ profile, serverId }) => {
|
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
|
setPendingRedirectServerId(serverId);
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pendingNameHost && pendingRedirectServerId ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => {
|
||||||
|
const serverId = pendingRedirectServerId;
|
||||||
|
setPendingNameHost(null);
|
||||||
|
setPendingRedirectServerId(null);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
}}
|
||||||
|
onSave={(label) => {
|
||||||
|
const serverId = pendingRedirectServerId;
|
||||||
|
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
setPendingRedirectServerId(null);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||||
|
|
||||||
export const FOOTER_HEIGHT = 75;
|
export const FOOTER_HEIGHT = 75;
|
||||||
@@ -62,13 +62,16 @@ export function getIsElectronRuntime(): boolean {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isCompactFormFactor(): boolean {
|
||||||
* Reactive hook — re-renders the component when the breakpoint changes.
|
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
|
}
|
||||||
*/
|
|
||||||
export function useIsCompactFormFactor(): boolean {
|
export function isDesktopFormFactor(): boolean {
|
||||||
const { rt } = useUnistyles();
|
return !isCompactFormFactor();
|
||||||
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
|
}
|
||||||
|
|
||||||
|
export function isTouchDesktopFormFactor(): boolean {
|
||||||
|
return Platform.OS !== "web" && isDesktopFormFactor();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
|
|||||||
import { useWindowDimensions } from "react-native";
|
import { useWindowDimensions } from "react-native";
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
@@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
|
|
||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useWindowDimensions } from "react-native";
|
import { useWindowDimensions } from "react-native";
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getLeftSidebarAnimationTargets,
|
getLeftSidebarAnimationTargets,
|
||||||
@@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
|||||||
|
|
||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +21,15 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||||
import {
|
import {
|
||||||
getCliDaemonStatus,
|
getCliDaemonStatus,
|
||||||
|
getDesktopDaemonLogs,
|
||||||
|
getDesktopDaemonStatus,
|
||||||
restartDesktopDaemon,
|
restartDesktopDaemon,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
startDesktopDaemon,
|
startDesktopDaemon,
|
||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
|
type DesktopDaemonLogs,
|
||||||
|
type DesktopDaemonStatus,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
@@ -36,18 +40,44 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const showSection = shouldUseDesktopDaemon();
|
const showSection = shouldUseDesktopDaemon();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
|
||||||
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||||
|
|
||||||
const daemonStatus = data?.status ?? null;
|
const loadDaemonData = useCallback(() => {
|
||||||
const daemonLogs = data?.logs ?? null;
|
if (!showSection) {
|
||||||
const daemonVersion = daemonStatus?.version ?? null;
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||||
|
.then(([status, logs]) => {
|
||||||
|
setDaemonStatus(status);
|
||||||
|
setDaemonLogs(logs);
|
||||||
|
setDaemonVersion(status.version);
|
||||||
|
setStatusError(null);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setStatusError(message);
|
||||||
|
});
|
||||||
|
}, [showSection]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!showSection) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
void loadDaemonData();
|
||||||
|
return undefined;
|
||||||
|
}, [loadDaemonData, showSection]),
|
||||||
|
);
|
||||||
|
|
||||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||||
const daemonStatusStateText =
|
const daemonStatusStateText =
|
||||||
@@ -86,12 +116,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
||||||
|
|
||||||
void action()
|
void action()
|
||||||
.then((newStatus) => {
|
.then((status) => {
|
||||||
setStatus(newStatus);
|
setDaemonStatus(status);
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
||||||
);
|
);
|
||||||
refetch();
|
return loadDaemonData();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to change desktop daemon state", error);
|
console.error("[Settings] Failed to change desktop daemon state", error);
|
||||||
@@ -106,7 +136,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||||
});
|
});
|
||||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
|
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
|
||||||
|
|
||||||
const handleToggleDaemonManagement = useCallback(() => {
|
const handleToggleDaemonManagement = useCallback(() => {
|
||||||
if (isUpdatingDaemonManagement) {
|
if (isUpdatingDaemonManagement) {
|
||||||
@@ -152,14 +182,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
: Promise.resolve(daemonStatus ?? null);
|
: Promise.resolve(daemonStatus ?? null);
|
||||||
|
|
||||||
void stopPromise
|
void stopPromise
|
||||||
.then((newStatus) => {
|
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||||
if (newStatus) {
|
.then(() => loadDaemonData())
|
||||||
setStatus(newStatus);
|
|
||||||
}
|
|
||||||
return updateSettings({ manageBuiltInDaemon: false });
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
refetch();
|
|
||||||
setStatusMessage("Built-in daemon paused and stopped.");
|
setStatusMessage("Built-in daemon paused and stopped.");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -177,8 +202,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
}, [
|
}, [
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
isUpdatingDaemonManagement,
|
isUpdatingDaemonManagement,
|
||||||
refetch,
|
loadDaemonData,
|
||||||
setStatus,
|
|
||||||
settings.manageBuiltInDaemon,
|
settings.manageBuiltInDaemon,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
]);
|
]);
|
||||||
@@ -253,138 +277,126 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
Advanced settings
|
Advanced settings
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
{isLoading ? (
|
<View style={settingsStyles.card}>
|
||||||
<View style={[settingsStyles.card, styles.loadingCard]}>
|
<View style={settingsStyles.row}>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statusValueGroup}>
|
||||||
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||||
|
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
{showLifecycleControls ? (
|
||||||
<>
|
<>
|
||||||
<View style={settingsStyles.card}>
|
|
||||||
<View style={settingsStyles.row}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
Only the built-in desktop daemon is shown here.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statusValueGroup}>
|
|
||||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
|
||||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{showLifecycleControls ? (
|
|
||||||
<>
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
{isDaemonManagementPaused
|
|
||||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
|
||||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={
|
|
||||||
isDaemonManagementPaused ? (
|
|
||||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
|
||||||
) : (
|
|
||||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPress={handleToggleDaemonManagement}
|
|
||||||
disabled={isUpdatingDaemonManagement}
|
|
||||||
>
|
|
||||||
{isUpdatingDaemonManagement
|
|
||||||
? isDaemonManagementPaused
|
|
||||||
? "Resuming..."
|
|
||||||
: "Pausing..."
|
|
||||||
: isDaemonManagementPaused
|
|
||||||
? "Resume"
|
|
||||||
: "Pause"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
|
||||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleUpdateLocalDaemon}
|
|
||||||
disabled={isRestartingDaemon}
|
|
||||||
>
|
|
||||||
{isRestartingDaemon
|
|
||||||
? daemonStatus?.status === "running"
|
|
||||||
? "Restarting..."
|
|
||||||
: "Starting..."
|
|
||||||
: daemonActionLabel}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
{daemonLogs?.logPath ?? "Log path unavailable."}
|
{isDaemonManagementPaused
|
||||||
</Text>
|
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||||
</View>
|
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||||
<View style={styles.actionGroup}>
|
|
||||||
{daemonLogs?.logPath ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleCopyLogPath}
|
|
||||||
>
|
|
||||||
Copy path
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleOpenLogs}
|
|
||||||
disabled={!daemonLogs}
|
|
||||||
>
|
|
||||||
Open logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
Runs `paseo daemon status` and shows the output.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
leftIcon={
|
||||||
onPress={() => void handleOpenCliStatus()}
|
isDaemonManagementPaused ? (
|
||||||
disabled={isLoadingCliStatus}
|
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
|
) : (
|
||||||
|
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={handleToggleDaemonManagement}
|
||||||
|
disabled={isUpdatingDaemonManagement}
|
||||||
>
|
>
|
||||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
{isUpdatingDaemonManagement
|
||||||
|
? isDaemonManagementPaused
|
||||||
|
? "Resuming..."
|
||||||
|
: "Pausing..."
|
||||||
|
: isDaemonManagementPaused
|
||||||
|
? "Resume"
|
||||||
|
: "Pause"}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
{daemonVersionMismatch ? (
|
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||||
<View style={styles.warningCard}>
|
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||||
<Text style={styles.warningText}>
|
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||||
App and daemon versions don't match. Update both to the same version for the best
|
</View>
|
||||||
experience.
|
<Button
|
||||||
</Text>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleUpdateLocalDaemon}
|
||||||
|
disabled={isRestartingDaemon}
|
||||||
|
>
|
||||||
|
{isRestartingDaemon
|
||||||
|
? daemonStatus?.status === "running"
|
||||||
|
? "Restarting..."
|
||||||
|
: "Starting..."
|
||||||
|
: daemonActionLabel}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
</>
|
||||||
</>
|
) : null}
|
||||||
)}
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionGroup}>
|
||||||
|
{daemonLogs?.logPath ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleCopyLogPath}
|
||||||
|
>
|
||||||
|
Copy path
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleOpenLogs}
|
||||||
|
disabled={!daemonLogs}
|
||||||
|
>
|
||||||
|
Open logs
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Runs `paseo daemon status` and shows the output.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={() => void handleOpenCliStatus()}
|
||||||
|
disabled={isLoadingCliStatus}
|
||||||
|
>
|
||||||
|
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{daemonVersionMismatch ? (
|
||||||
|
<View style={styles.warningCard}>
|
||||||
|
<Text style={styles.warningText}>
|
||||||
|
App and daemon versions don't match. Update both to the same version for the best
|
||||||
|
experience.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
visible={isLogsModalOpen}
|
visible={isLogsModalOpen}
|
||||||
@@ -435,11 +447,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
loadingCard: {
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingVertical: theme.spacing[6],
|
|
||||||
},
|
|
||||||
statusValueGroup: {
|
statusValueGroup: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getDesktopDaemonLogs,
|
|
||||||
getDesktopDaemonStatus,
|
|
||||||
shouldUseDesktopDaemon,
|
|
||||||
type DesktopDaemonLogs,
|
|
||||||
type DesktopDaemonStatus,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
|
||||||
|
|
||||||
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
|
|
||||||
|
|
||||||
interface DaemonStatusData {
|
|
||||||
status: DesktopDaemonStatus;
|
|
||||||
logs: DesktopDaemonLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDaemonStatus() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const enabled = shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
const query = useQuery<DaemonStatusData>({
|
|
||||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
|
||||||
enabled,
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
queryFn: async () => {
|
|
||||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
|
||||||
return { status, logs };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setStatus = useCallback(
|
|
||||||
(status: DesktopDaemonStatus) => {
|
|
||||||
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
|
|
||||||
prev ? { ...prev, status } : undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refetch = useCallback(() => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: query.data ?? null,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
error: query.error instanceof Error ? query.error.message : null,
|
|
||||||
setStatus,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
|||||||
|
|
||||||
export interface DesktopAppUpdateCheckResult {
|
export interface DesktopAppUpdateCheckResult {
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
readyToInstall: boolean;
|
|
||||||
currentVersion: string | null;
|
currentVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
body: string | null;
|
body: string | null;
|
||||||
@@ -77,7 +76,6 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasUpdate: result.hasUpdate === true,
|
hasUpdate: result.hasUpdate === true,
|
||||||
readyToInstall: result.readyToInstall === true,
|
|
||||||
currentVersion: toStringOrNull(result.currentVersion),
|
currentVersion: toStringOrNull(result.currentVersion),
|
||||||
latestVersion: toStringOrNull(result.latestVersion),
|
latestVersion: toStringOrNull(result.latestVersion),
|
||||||
body: toStringOrNull(result.body),
|
body: toStringOrNull(result.body),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function UpdateBanner() {
|
|||||||
|
|
||||||
function getSubtitle(): string {
|
function getSubtitle(): string {
|
||||||
if (isInstalled) return "Restart to use the new version.";
|
if (isInstalled) return "Restart to use the new version.";
|
||||||
if (isInstalling) return "Installing and restarting...";
|
if (isInstalling) return "Downloading and installing...";
|
||||||
if (isError) return errorMessage ?? "Something went wrong.";
|
if (isError) return errorMessage ?? "Something went wrong.";
|
||||||
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
|
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
checkDesktopAppUpdate,
|
checkDesktopAppUpdate,
|
||||||
formatVersionWithPrefix,
|
formatVersionWithPrefix,
|
||||||
@@ -11,15 +11,12 @@ import {
|
|||||||
export type DesktopAppUpdateStatus =
|
export type DesktopAppUpdateStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "checking"
|
| "checking"
|
||||||
| "pending"
|
|
||||||
| "up-to-date"
|
| "up-to-date"
|
||||||
| "available"
|
| "available"
|
||||||
| "installing"
|
| "installing"
|
||||||
| "installed"
|
| "installed"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
const PENDING_RECHECK_MS = 10_000;
|
|
||||||
|
|
||||||
export interface UseDesktopAppUpdaterReturn {
|
export interface UseDesktopAppUpdaterReturn {
|
||||||
isDesktopApp: boolean;
|
isDesktopApp: boolean;
|
||||||
status: DesktopAppUpdateStatus;
|
status: DesktopAppUpdateStatus;
|
||||||
@@ -59,15 +56,11 @@ function formatStatusText(input: {
|
|||||||
return "App is up to date.";
|
return "App is up to date.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
|
||||||
return "We'll let you know when the update is ready.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "available") {
|
if (status === "available") {
|
||||||
if (availableUpdate?.latestVersion) {
|
if (availableUpdate?.latestVersion) {
|
||||||
return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
|
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
|
||||||
}
|
}
|
||||||
return "An app update is ready to install.";
|
return "An app update is available.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "installed") {
|
if (status === "installed") {
|
||||||
@@ -113,12 +106,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
setInstallMessage(null);
|
setInstallMessage(null);
|
||||||
setLastCheckedAt(Date.now());
|
setLastCheckedAt(Date.now());
|
||||||
|
|
||||||
if (result.readyToInstall) {
|
if (result.hasUpdate) {
|
||||||
setAvailableUpdate(result);
|
setAvailableUpdate(result);
|
||||||
setStatus("available");
|
setStatus("available");
|
||||||
} else if (result.hasUpdate) {
|
|
||||||
setAvailableUpdate(null);
|
|
||||||
setStatus("pending");
|
|
||||||
} else {
|
} else {
|
||||||
setAvailableUpdate(null);
|
setAvailableUpdate(null);
|
||||||
setStatus("up-to-date");
|
setStatus("up-to-date");
|
||||||
@@ -143,20 +133,6 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
[isDesktopApp],
|
[isDesktopApp],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDesktopApp || status !== "pending") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
void checkForUpdates({ silent: true });
|
|
||||||
}, PENDING_RECHECK_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [checkForUpdates, isDesktopApp, status]);
|
|
||||||
|
|
||||||
const installUpdate = useCallback(async () => {
|
const installUpdate = useCallback(async () => {
|
||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { resolveFeatureValues } from "./feature-preferences";
|
|
||||||
|
|
||||||
describe("feature-preferences", () => {
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
id: "fast_mode",
|
|
||||||
label: "Fast",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("restores persisted values for available features", () => {
|
|
||||||
expect(
|
|
||||||
resolveFeatureValues({
|
|
||||||
features,
|
|
||||||
persistedFeatureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
unknown_feature: true,
|
|
||||||
},
|
|
||||||
localFeatureValues: {},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
fast_mode: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers local values over persisted values", () => {
|
|
||||||
expect(
|
|
||||||
resolveFeatureValues({
|
|
||||||
features,
|
|
||||||
persistedFeatureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
plan_mode: false,
|
|
||||||
},
|
|
||||||
localFeatureValues: {
|
|
||||||
fast_mode: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
fast_mode: false,
|
|
||||||
plan_mode: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { AgentFeature } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
export function pruneFeatureValues(
|
|
||||||
featureValues: Record<string, unknown>,
|
|
||||||
features: AgentFeature[],
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
|
||||||
let changed = false;
|
|
||||||
const next: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
|
||||||
if (!allowedFeatureIds.has(featureId)) {
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[featureId] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : featureValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyFeatureValues(
|
|
||||||
features: AgentFeature[],
|
|
||||||
featureValues: Record<string, unknown>,
|
|
||||||
): AgentFeature[] {
|
|
||||||
if (Object.keys(featureValues).length === 0) {
|
|
||||||
return features;
|
|
||||||
}
|
|
||||||
|
|
||||||
return features.map((feature) => {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
|
||||||
return feature;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...feature,
|
|
||||||
value: featureValues[feature.id],
|
|
||||||
} as AgentFeature;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFeatureValues(args: {
|
|
||||||
features: AgentFeature[];
|
|
||||||
persistedFeatureValues: Record<string, unknown>;
|
|
||||||
localFeatureValues: Record<string, unknown>;
|
|
||||||
}): Record<string, unknown> {
|
|
||||||
const next: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const feature of args.features) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
|
|
||||||
next[feature.id] = args.localFeatureValues[feature.id];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
|
|
||||||
next[feature.id] = args.persistedFeatureValues[feature.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,6 @@ type UseAgentFormStateResult = {
|
|||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
modelError: string | null;
|
modelError: string | null;
|
||||||
refreshProviderModels: () => void;
|
refreshProviderModels: () => void;
|
||||||
invalidateProviderModels: () => void;
|
|
||||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||||
workingDirIsEmpty: boolean;
|
workingDirIsEmpty: boolean;
|
||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
@@ -376,7 +375,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
error: snapshotError,
|
error: snapshotError,
|
||||||
refresh: refreshSnapshot,
|
refresh: refreshSnapshot,
|
||||||
invalidate: invalidateSnapshot,
|
|
||||||
} = useProvidersSnapshot(formState.serverId);
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
@@ -650,36 +648,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshSnapshot();
|
refreshSnapshot();
|
||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const invalidateProviderModels = useCallback(() => {
|
|
||||||
invalidateSnapshot();
|
|
||||||
}, [invalidateSnapshot]);
|
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const modelId = resolvedModel?.id ?? formState.model;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
await updatePreferences((current) =>
|
const nextPreferences = mergeProviderPreferences({
|
||||||
mergeProviderPreferences({
|
preferences: preferences ?? {},
|
||||||
preferences: current,
|
provider: formState.provider,
|
||||||
provider: formState.provider,
|
updates: {
|
||||||
updates: {
|
model: modelId || undefined,
|
||||||
model: modelId || undefined,
|
mode: formState.modeId || undefined,
|
||||||
mode: formState.modeId || undefined,
|
...(modelId && formState.thinkingOptionId
|
||||||
...(modelId && formState.thinkingOptionId
|
? {
|
||||||
? {
|
thinkingByModel: {
|
||||||
thinkingByModel: {
|
[modelId]: formState.thinkingOptionId,
|
||||||
[modelId]: formState.thinkingOptionId,
|
},
|
||||||
},
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
} satisfies Partial<ProviderPreferences>,
|
||||||
} satisfies Partial<ProviderPreferences>,
|
});
|
||||||
}),
|
|
||||||
);
|
await updatePreferences(nextPreferences);
|
||||||
}, [
|
}, [
|
||||||
availableModels,
|
availableModels,
|
||||||
formState.model,
|
formState.model,
|
||||||
formState.modeId,
|
formState.modeId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
|
preferences?.providerPreferences,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -720,7 +715,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
invalidateProviderModels,
|
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
@@ -752,7 +746,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
invalidateProviderModels,
|
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
|
||||||
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
|
||||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
|
||||||
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
||||||
|
|
||||||
interface UseBranchSwitcherInput {
|
|
||||||
client: DaemonClient | null;
|
|
||||||
normalizedServerId: string;
|
|
||||||
normalizedWorkspaceId: string;
|
|
||||||
currentBranchName: string | null;
|
|
||||||
isGitCheckout: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
toast: ToastApi;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseBranchSwitcherResult {
|
|
||||||
branchOptions: ComboboxOption[];
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
handleBranchSelect: (branchId: string) => void;
|
|
||||||
invalidateStashAndCheckout: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBranchSwitcher({
|
|
||||||
client,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
currentBranchName,
|
|
||||||
isGitCheckout,
|
|
||||||
isConnected,
|
|
||||||
toast,
|
|
||||||
queryClient,
|
|
||||||
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const branchSuggestionsQuery = useQuery({
|
|
||||||
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.getBranchSuggestions({
|
|
||||||
cwd: normalizedWorkspaceId,
|
|
||||||
limit: 200,
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return payload.branches ?? [];
|
|
||||||
},
|
|
||||||
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
|
|
||||||
retry: false,
|
|
||||||
staleTime: 15_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const branchOptions = useMemo<ComboboxOption[]>(() => {
|
|
||||||
const branches = branchSuggestionsQuery.data ?? [];
|
|
||||||
return branches.map((name) => ({ id: name, label: name }));
|
|
||||||
}, [branchSuggestionsQuery.data]);
|
|
||||||
|
|
||||||
const stashListQueryKey = useMemo(
|
|
||||||
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
|
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidateStashAndCheckout = useCallback(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
|
|
||||||
|
|
||||||
const stashAndSwitch = useCallback(
|
|
||||||
async (branchId: string) => {
|
|
||||||
if (!client) return;
|
|
||||||
const shouldStash = await confirmDialog({
|
|
||||||
title: "Uncommitted changes",
|
|
||||||
message:
|
|
||||||
"You have uncommitted changes. Stash them before switching branches?",
|
|
||||||
confirmLabel: "Stash & Switch",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
});
|
|
||||||
if (!shouldStash) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
|
|
||||||
branch: currentBranchName ?? undefined,
|
|
||||||
});
|
|
||||||
if (stashPayload.error) {
|
|
||||||
toast.error(stashPayload.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await invalidateStashAndCheckout();
|
|
||||||
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
|
||||||
if (switchPayload.error) {
|
|
||||||
toast.error(switchPayload.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await invalidateStashAndCheckout();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBranchSelect = useCallback(
|
|
||||||
(branchId: string) => {
|
|
||||||
if (branchId === currentBranchName) return;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
if (!client) return;
|
|
||||||
try {
|
|
||||||
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
|
||||||
if (payload.error) {
|
|
||||||
// If the error is about uncommitted changes, offer the stash dialog
|
|
||||||
if (payload.error.message.toLowerCase().includes("uncommitted")) {
|
|
||||||
await stashAndSwitch(branchId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.error(payload.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Success — refresh and check for stashes on the target branch
|
|
||||||
await invalidateStashAndCheckout();
|
|
||||||
try {
|
|
||||||
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
|
|
||||||
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
|
|
||||||
if (targetStash) {
|
|
||||||
const shouldRestore = await confirmDialog({
|
|
||||||
title: "Restore stashed changes?",
|
|
||||||
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
|
|
||||||
confirmLabel: "Restore",
|
|
||||||
cancelLabel: "Later",
|
|
||||||
});
|
|
||||||
if (shouldRestore) {
|
|
||||||
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
|
|
||||||
if (popPayload.error) {
|
|
||||||
toast.error(popPayload.error.message);
|
|
||||||
} else {
|
|
||||||
toast.show("Stashed changes restored");
|
|
||||||
}
|
|
||||||
await invalidateStashAndCheckout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-critical — user can still restore on next branch switch
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||||
@@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||||
@@ -32,7 +32,7 @@ function fetchCheckoutStatus(
|
|||||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
|
|||||||
@@ -1,16 +1,50 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
AgentFeature,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
|
||||||
import {
|
function pruneFeatureValues(
|
||||||
applyFeatureValues,
|
featureValues: Record<string, unknown>,
|
||||||
pruneFeatureValues,
|
features: AgentFeature[],
|
||||||
resolveFeatureValues,
|
): Record<string, unknown> {
|
||||||
} from "./feature-preferences";
|
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [featureId, value] of Object.entries(featureValues)) {
|
||||||
|
if (!allowedFeatureIds.has(featureId)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[featureId] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : featureValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFeatureValues(
|
||||||
|
features: AgentFeature[],
|
||||||
|
featureValues: Record<string, unknown>,
|
||||||
|
): AgentFeature[] {
|
||||||
|
if (Object.keys(featureValues).length === 0) {
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
return features.map((feature) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
value: featureValues[feature.id],
|
||||||
|
} as AgentFeature;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type DraftFeatureConfig = Pick<
|
type DraftFeatureConfig = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -26,15 +60,10 @@ export function useDraftAgentFeatures(input: {
|
|||||||
thinkingOptionId: string | null | undefined;
|
thinkingOptionId: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
|
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
|
||||||
const normalizedCwd = cwd?.trim() || "";
|
const normalizedCwd = cwd?.trim() || "";
|
||||||
const persistedFeatureValues = useMemo(
|
|
||||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
|
||||||
[preferences.providerPreferences, provider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||||
if (!normalizedCwd) {
|
if (!normalizedCwd) {
|
||||||
@@ -73,56 +102,28 @@ export function useDraftAgentFeatures(input: {
|
|||||||
return payload.features ?? [];
|
return payload.features ?? [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const availableFeatures = featuresQuery.data ?? [];
|
|
||||||
const featureValues = useMemo(
|
|
||||||
() =>
|
|
||||||
resolveFeatureValues({
|
|
||||||
features: availableFeatures,
|
|
||||||
persistedFeatureValues,
|
|
||||||
localFeatureValues,
|
|
||||||
}),
|
|
||||||
[availableFeatures, localFeatureValues, persistedFeatureValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const features = useMemo(() => {
|
const features = useMemo(() => {
|
||||||
return applyFeatureValues(availableFeatures, featureValues);
|
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
|
||||||
}, [availableFeatures, featureValues]);
|
}, [featureValues, featuresQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalFeatureValues({});
|
const next = pruneFeatureValues(featureValues, features);
|
||||||
}, [provider]);
|
if (next !== featureValues) {
|
||||||
|
setFeatureValues(next);
|
||||||
useEffect(() => {
|
|
||||||
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
|
|
||||||
if (next !== localFeatureValues) {
|
|
||||||
setLocalFeatureValues(next);
|
|
||||||
}
|
}
|
||||||
}, [availableFeatures, localFeatureValues]);
|
}, [featureValues, features]);
|
||||||
|
|
||||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||||
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
|
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
|
||||||
setLocalFeatureValues((current) => {
|
setFeatureValues((current) => {
|
||||||
if (Object.is(current[featureId], value)) {
|
if (Object.is(current[featureId], value)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...current, [featureId]: value };
|
return { ...current, [featureId]: value };
|
||||||
});
|
});
|
||||||
void updatePreferences(
|
}, []);
|
||||||
(current) =>
|
|
||||||
mergeProviderPreferences({
|
|
||||||
preferences: current,
|
|
||||||
provider,
|
|
||||||
updates: {
|
|
||||||
featureValues: {
|
|
||||||
[featureId]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
|
|
||||||
});
|
|
||||||
}, [provider, updatePreferences]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features,
|
features,
|
||||||
|
|||||||
@@ -59,41 +59,6 @@ describe("mergeProviderPreferences", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges feature values without dropping existing entries", () => {
|
|
||||||
expect(
|
|
||||||
mergeProviderPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
featureValues: {
|
|
||||||
plan_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
plan_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("favorite model preferences", () => {
|
describe("favorite model preferences", () => {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const providerPreferencesSchema = z.object({
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
thinkingByModel: z.record(z.string()).optional(),
|
thinkingByModel: z.record(z.string()).optional(),
|
||||||
featureValues: z.record(z.unknown()).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
@@ -54,9 +53,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
|||||||
export interface UseFormPreferencesReturn {
|
export interface UseFormPreferencesReturn {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
updatePreferences: (
|
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeProviderPreferences(args: {
|
export function mergeProviderPreferences(args: {
|
||||||
@@ -74,13 +71,6 @@ export function mergeProviderPreferences(args: {
|
|||||||
...existing.thinkingByModel,
|
...existing.thinkingByModel,
|
||||||
...updates.thinkingByModel,
|
...updates.thinkingByModel,
|
||||||
};
|
};
|
||||||
const nextFeatureValues =
|
|
||||||
updates.featureValues === undefined
|
|
||||||
? existing.featureValues
|
|
||||||
: {
|
|
||||||
...existing.featureValues,
|
|
||||||
...updates.featureValues,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...preferences,
|
...preferences,
|
||||||
@@ -91,7 +81,6 @@ export function mergeProviderPreferences(args: {
|
|||||||
...existing,
|
...existing,
|
||||||
...updates,
|
...updates,
|
||||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -144,12 +133,11 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||||
|
|
||||||
const updatePreferences = useCallback(
|
const updatePreferences = useCallback(
|
||||||
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
|
async (updates: Partial<FormPreferences>) => {
|
||||||
const prev =
|
const prev =
|
||||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
||||||
DEFAULT_FORM_PREFERENCES;
|
DEFAULT_FORM_PREFERENCES;
|
||||||
const next =
|
const next = { ...prev, ...updates };
|
||||||
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
|
|
||||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface UseProvidersSnapshotResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
invalidate: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||||
@@ -67,10 +66,6 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
void client.refreshProvidersSnapshot();
|
void client.refreshProvidersSnapshot();
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const invalidate = useCallback(() => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
}, [queryClient, queryKey]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: snapshotQuery.data?.entries ?? undefined,
|
entries: snapshotQuery.data?.entries ?? undefined,
|
||||||
isLoading: snapshotQuery.isLoading,
|
isLoading: snapshotQuery.isLoading,
|
||||||
@@ -78,7 +73,6 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||||
supportsSnapshot,
|
supportsSnapshot,
|
||||||
refresh,
|
refresh,
|
||||||
invalidate,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,22 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
|||||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||||
|
|
||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|
||||||
|
|
||||||
export type SendBehavior = "interrupt" | "queue";
|
|
||||||
|
|
||||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: ThemeName | "auto";
|
theme: "dark" | "light" | "auto";
|
||||||
manageBuiltInDaemon: boolean;
|
manageBuiltInDaemon: boolean;
|
||||||
sendBehavior: SendBehavior;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
theme: "auto",
|
theme: "auto",
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
sendBehavior: "interrupt",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UseAppSettingsReturn {
|
export interface UseAppSettingsReturn {
|
||||||
@@ -82,9 +74,6 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
|||||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||||
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
|
||||||
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
|
||||||
}
|
|
||||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
FetchAgentsEntry,
|
FetchAgentsEntry,
|
||||||
FetchAgentsOptions,
|
FetchAgentsOptions,
|
||||||
} from "@server/client/daemon-client";
|
} from "@server/client/daemon-client";
|
||||||
import type { ConnectionOffer } from "@server/shared/connection-offer";
|
|
||||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -190,17 +189,6 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
|
||||||
return {
|
|
||||||
v: 2,
|
|
||||||
serverId: input?.serverId ?? "srv_offer",
|
|
||||||
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
|
|
||||||
relay: {
|
|
||||||
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDeps(
|
function makeDeps(
|
||||||
latencyByConnectionId: Record<string, number | Error>,
|
latencyByConnectionId: Record<string, number | Error>,
|
||||||
createdClients: FakeDaemonClient[],
|
createdClients: FakeDaemonClient[],
|
||||||
@@ -1307,53 +1295,4 @@ describe("HostRuntimeStore", () => {
|
|||||||
|
|
||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async ({ host }) => ({
|
|
||||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
|
||||||
serverId: host.serverId,
|
|
||||||
hostname: host.label ?? null,
|
|
||||||
}),
|
|
||||||
getClientId: async () => "cid_test_runtime",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
|
||||||
|
|
||||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
|
||||||
expect(pairedHost?.label).toBe("mbp");
|
|
||||||
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async ({ host }) => ({
|
|
||||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
|
||||||
serverId: host.serverId,
|
|
||||||
hostname: host.label ?? null,
|
|
||||||
}),
|
|
||||||
getClientId: async () => "cid_test_runtime",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.upsertRelayConnection({
|
|
||||||
serverId: "srv_offer",
|
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
|
||||||
daemonPublicKeyB64: "pk_test_offer",
|
|
||||||
label: "Custom name",
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
|
||||||
|
|
||||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
|
||||||
expect(pairedHost?.label).toBe("Custom name");
|
|
||||||
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1287,22 +1287,15 @@ export class HostRuntimeStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertConnectionFromOffer(
|
async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
|
||||||
offer: ConnectionOffer,
|
|
||||||
label?: string,
|
|
||||||
): Promise<HostProfile> {
|
|
||||||
return this.upsertRelayConnection({
|
return this.upsertRelayConnection({
|
||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
relayEndpoint: offer.relay.endpoint,
|
relayEndpoint: offer.relay.endpoint,
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
label,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertConnectionFromOfferUrl(
|
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
|
||||||
offerUrlOrFragment: string,
|
|
||||||
label?: string,
|
|
||||||
): Promise<HostProfile> {
|
|
||||||
const marker = "#offer=";
|
const marker = "#offer=";
|
||||||
const idx = offerUrlOrFragment.indexOf(marker);
|
const idx = offerUrlOrFragment.indexOf(marker);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
@@ -1314,7 +1307,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
const payload = decodeOfferFragmentPayload(encoded);
|
const payload = decodeOfferFragmentPayload(encoded);
|
||||||
const offer = ConnectionOfferSchema.parse(payload);
|
const offer = ConnectionOfferSchema.parse(payload);
|
||||||
return this.upsertConnectionFromOffer(offer, label);
|
return this.upsertConnectionFromOffer(offer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addConnectionFromListenAndWaitForOnline(input: {
|
async addConnectionFromListenAndWaitForOnline(input: {
|
||||||
@@ -1963,11 +1956,8 @@ export interface HostMutations {
|
|||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) => Promise<HostProfile>;
|
}) => Promise<HostProfile>;
|
||||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
|
||||||
upsertConnectionFromOfferUrl: (
|
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||||
offerUrlOrFragment: string,
|
|
||||||
label?: string,
|
|
||||||
) => Promise<HostProfile>;
|
|
||||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
removeHost: (serverId: string) => Promise<void>;
|
removeHost: (serverId: string) => Promise<void>;
|
||||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
@@ -1979,8 +1969,8 @@ export function useHostMutations(): HostMutations {
|
|||||||
() => ({
|
() => ({
|
||||||
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
||||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
|
||||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
|
||||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||||
removeHost: (serverId) => store.removeHost(serverId),
|
removeHost: (serverId) => store.removeHost(serverId),
|
||||||
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
@@ -226,7 +226,6 @@ function DraftAgentScreenContent({
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
invalidateProviderModels,
|
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
} = useAgentFormState({
|
} = useAgentFormState({
|
||||||
@@ -236,7 +235,7 @@ function DraftAgentScreenContent({
|
|||||||
isCreateFlow: true,
|
isCreateFlow: true,
|
||||||
onlineServerIds,
|
onlineServerIds,
|
||||||
});
|
});
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
@@ -1269,7 +1268,6 @@ function DraftAgentScreenContent({
|
|||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||||
features: draftFeatures,
|
features: draftFeatures,
|
||||||
onSetFeature: setDraftFeatureValue,
|
onSetFeature: setDraftFeatureValue,
|
||||||
onModelSelectorOpen: invalidateProviderModels,
|
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
|||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
@@ -17,7 +17,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCompactLayout) {
|
if (!isCompactLayout) {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Monitor,
|
Monitor,
|
||||||
ChevronDown,
|
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Server,
|
Server,
|
||||||
|
Palette,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
Info,
|
Info,
|
||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
Blocks,
|
Blocks,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import { THEME_SWATCHES, type ThemeName } from "@/styles/theme";
|
|
||||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
@@ -43,13 +42,13 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
|||||||
import { AddHostModal } from "@/components/add-host-modal";
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||||
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
@@ -66,7 +65,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
|
|||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
@@ -78,7 +77,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
|||||||
|
|
||||||
type SettingsSectionId =
|
type SettingsSectionId =
|
||||||
| "hosts"
|
| "hosts"
|
||||||
| "general"
|
| "appearance"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
| "providers"
|
| "providers"
|
||||||
@@ -97,21 +96,21 @@ interface SettingsSectionDef {
|
|||||||
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
|
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
|
||||||
const sections: SettingsSectionDef[] = [
|
const sections: SettingsSectionDef[] = [
|
||||||
{ id: "hosts", label: "Hosts", icon: Server },
|
{ id: "hosts", label: "Hosts", icon: Server },
|
||||||
{ id: "general", label: "General", icon: Settings },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
|
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (context.isDesktopApp) {
|
if (context.isDesktopApp) {
|
||||||
sections.push(
|
sections.push(
|
||||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
|
||||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||||
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||||
{ id: "providers", label: "Providers", icon: Blocks },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sections.push(
|
sections.push(
|
||||||
|
{ id: "providers", label: "Providers", icon: Blocks },
|
||||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||||
{ id: "about", label: "About", icon: Info },
|
{ id: "about", label: "About", icon: Info },
|
||||||
);
|
);
|
||||||
@@ -205,6 +204,10 @@ interface HostsSectionProps {
|
|||||||
goBackToAddConnectionMethods: () => void;
|
goBackToAddConnectionMethods: () => void;
|
||||||
setIsDirectHostVisible: (visible: boolean) => void;
|
setIsDirectHostVisible: (visible: boolean) => void;
|
||||||
setIsPasteLinkVisible: (visible: boolean) => void;
|
setIsPasteLinkVisible: (visible: boolean) => void;
|
||||||
|
pendingNameHost: { serverId: string; hostname: string | null } | null;
|
||||||
|
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
|
||||||
|
pendingNameHostname: string | null;
|
||||||
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
pendingRemoveHost: HostProfile | null;
|
pendingRemoveHost: HostProfile | null;
|
||||||
setPendingRemoveHost: (host: HostProfile | null) => void;
|
setPendingRemoveHost: (host: HostProfile | null) => void;
|
||||||
isRemovingHost: boolean;
|
isRemovingHost: boolean;
|
||||||
@@ -287,14 +290,38 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
visible={props.isDirectHostVisible}
|
visible={props.isDirectHostVisible}
|
||||||
onClose={props.closeAddConnectionFlow}
|
onClose={props.closeAddConnectionFlow}
|
||||||
onCancel={props.goBackToAddConnectionMethods}
|
onCancel={props.goBackToAddConnectionMethods}
|
||||||
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
|
if (isNewHost) {
|
||||||
|
props.setPendingNameHost({ serverId, hostname });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={props.isPasteLinkVisible}
|
visible={props.isPasteLinkVisible}
|
||||||
onClose={props.closeAddConnectionFlow}
|
onClose={props.closeAddConnectionFlow}
|
||||||
onCancel={props.goBackToAddConnectionMethods}
|
onCancel={props.goBackToAddConnectionMethods}
|
||||||
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
|
if (isNewHost) {
|
||||||
|
props.setPendingNameHost({ serverId, hostname });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{props.pendingNameHost ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={props.pendingNameHost.serverId}
|
||||||
|
hostname={props.pendingNameHostname}
|
||||||
|
onSkip={() => props.setPendingNameHost(null)}
|
||||||
|
onSave={(label) => {
|
||||||
|
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
|
||||||
|
props.setPendingNameHost(null);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{props.pendingRemoveHost ? (
|
{props.pendingRemoveHost ? (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title="Remove host"
|
title="Remove host"
|
||||||
@@ -359,119 +386,41 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeneralSectionProps {
|
interface AppearanceSectionProps {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
|
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
|
||||||
switch (theme) {
|
|
||||||
case "light":
|
|
||||||
return <Sun size={size} color={color} />;
|
|
||||||
case "dark":
|
|
||||||
return <Moon size={size} color={color} />;
|
|
||||||
case "auto":
|
|
||||||
return <Monitor size={size} color={color} />;
|
|
||||||
default:
|
|
||||||
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeSwatch({ color, size }: { color: string; size: number }) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
borderRadius: size / 2,
|
|
||||||
backgroundColor: color,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "rgba(255,255,255,0.15)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
|
||||||
light: "Light",
|
|
||||||
dark: "Dark",
|
|
||||||
zinc: "Zinc",
|
|
||||||
midnight: "Midnight",
|
|
||||||
claude: "Claude",
|
|
||||||
ghostty: "Ghostty",
|
|
||||||
auto: "System",
|
|
||||||
};
|
|
||||||
|
|
||||||
function GeneralSection({
|
|
||||||
settings,
|
|
||||||
handleThemeChange,
|
|
||||||
handleSendBehaviorChange,
|
|
||||||
}: GeneralSectionProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const iconSize = theme.iconSize.md;
|
|
||||||
const iconColor = theme.colors.foregroundMuted;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section}>
|
||||||
<Text style={settingsStyles.sectionTitle}>General</Text>
|
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
|
||||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||||
<View style={styles.audioRow}>
|
<View style={styles.audioRow}>
|
||||||
<View style={styles.audioRowContent}>
|
<View style={styles.audioRowContent}>
|
||||||
<Text style={styles.audioRowTitle}>Theme</Text>
|
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.themeTrigger,
|
|
||||||
pressed && { opacity: 0.85 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
|
|
||||||
<Text style={styles.themeTriggerText}>
|
|
||||||
{THEME_LABELS[settings.theme]}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
|
||||||
{(["light", "dark", "auto"] as const).map((t) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={t}
|
|
||||||
selected={settings.theme === t}
|
|
||||||
onSelect={() => handleThemeChange(t)}
|
|
||||||
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
|
|
||||||
>
|
|
||||||
{THEME_LABELS[t]}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={t}
|
|
||||||
selected={settings.theme === t}
|
|
||||||
onSelect={() => handleThemeChange(t)}
|
|
||||||
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
|
|
||||||
>
|
|
||||||
{THEME_LABELS[t]}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</View>
|
|
||||||
<View style={styles.audioRow}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Default send</Text>
|
|
||||||
<Text style={styles.audioRowSubtitle}>
|
|
||||||
What happens when you press Enter while the agent is running
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="sm"
|
size="sm"
|
||||||
value={settings.sendBehavior}
|
hideLabels={Platform.OS !== "web"}
|
||||||
onValueChange={handleSendBehaviorChange}
|
value={settings.theme}
|
||||||
|
onValueChange={handleThemeChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: "interrupt", label: "Interrupt" },
|
{
|
||||||
{ value: "queue", label: "Queue" },
|
value: "light",
|
||||||
|
label: "Light",
|
||||||
|
icon: ({ color, size }) => <Sun size={size} color={color} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
icon: ({ color, size }) => <Moon size={size} color={color} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: "System",
|
||||||
|
icon: ({ color, size }) => <Monitor size={size} color={color} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -665,7 +614,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
|||||||
interface SettingsSectionContentProps {
|
interface SettingsSectionContentProps {
|
||||||
sectionId: SettingsSectionId;
|
sectionId: SettingsSectionId;
|
||||||
hostsProps: HostsSectionProps;
|
hostsProps: HostsSectionProps;
|
||||||
generalProps: GeneralSectionProps;
|
appearanceProps: AppearanceSectionProps;
|
||||||
providersProps: ProvidersSectionProps;
|
providersProps: ProvidersSectionProps;
|
||||||
diagnosticsProps: DiagnosticsSectionProps;
|
diagnosticsProps: DiagnosticsSectionProps;
|
||||||
aboutProps: AboutSectionProps;
|
aboutProps: AboutSectionProps;
|
||||||
@@ -677,7 +626,7 @@ interface SettingsSectionContentProps {
|
|||||||
function SettingsSectionContent({
|
function SettingsSectionContent({
|
||||||
sectionId,
|
sectionId,
|
||||||
hostsProps,
|
hostsProps,
|
||||||
generalProps,
|
appearanceProps,
|
||||||
providersProps,
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
@@ -688,8 +637,8 @@ function SettingsSectionContent({
|
|||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case "hosts":
|
case "hosts":
|
||||||
return <HostsSection {...hostsProps} />;
|
return <HostsSection {...hostsProps} />;
|
||||||
case "general":
|
case "appearance":
|
||||||
return <GeneralSection {...generalProps} />;
|
return <AppearanceSection {...appearanceProps} />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
return <KeyboardShortcutsSection />;
|
return <KeyboardShortcutsSection />;
|
||||||
case "providers":
|
case "providers":
|
||||||
@@ -866,7 +815,7 @@ function DesktopAppUpdateRow() {
|
|||||||
<Text style={styles.aboutHintText}>{statusText}</Text>
|
<Text style={styles.aboutHintText}>{statusText}</Text>
|
||||||
{availableUpdate?.latestVersion ? (
|
{availableUpdate?.latestVersion ? (
|
||||||
<Text style={styles.aboutHintText}>
|
<Text style={styles.aboutHintText}>
|
||||||
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
||||||
@@ -914,6 +863,10 @@ export default function SettingsScreen() {
|
|||||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
} | null>(null);
|
||||||
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
|
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
|
||||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||||
@@ -931,6 +884,19 @@ export default function SettingsScreen() {
|
|||||||
const editingDaemonLive = editingServerId
|
const editingDaemonLive = editingServerId
|
||||||
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
|
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return (
|
||||||
|
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||||
|
pendingNameHost.hostname ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pendingNameHost],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1037,13 +1003,6 @@ export default function SettingsScreen() {
|
|||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendBehaviorChange = useCallback(
|
|
||||||
(behavior: SendBehavior) => {
|
|
||||||
void updateSettings({ sendBehavior: behavior });
|
|
||||||
},
|
|
||||||
[updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlaybackTest = useCallback(async () => {
|
const handlePlaybackTest = useCallback(async () => {
|
||||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||||
return;
|
return;
|
||||||
@@ -1073,7 +1032,7 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const sections = getSettingsSections({ isDesktopApp });
|
const sections = getSettingsSections({ isDesktopApp });
|
||||||
|
|
||||||
const hostsProps: HostsSectionProps = {
|
const hostsProps: HostsSectionProps = {
|
||||||
@@ -1090,6 +1049,10 @@ export default function SettingsScreen() {
|
|||||||
goBackToAddConnectionMethods,
|
goBackToAddConnectionMethods,
|
||||||
setIsDirectHostVisible,
|
setIsDirectHostVisible,
|
||||||
setIsPasteLinkVisible,
|
setIsPasteLinkVisible,
|
||||||
|
pendingNameHost,
|
||||||
|
setPendingNameHost,
|
||||||
|
pendingNameHostname,
|
||||||
|
renameHost,
|
||||||
pendingRemoveHost,
|
pendingRemoveHost,
|
||||||
setPendingRemoveHost,
|
setPendingRemoveHost,
|
||||||
isRemovingHost,
|
isRemovingHost,
|
||||||
@@ -1106,10 +1069,9 @@ export default function SettingsScreen() {
|
|||||||
isMountedRef,
|
isMountedRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
const generalProps: GeneralSectionProps = {
|
const appearanceProps: AppearanceSectionProps = {
|
||||||
settings,
|
settings,
|
||||||
handleThemeChange,
|
handleThemeChange,
|
||||||
handleSendBehaviorChange,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const diagnosticsProps: DiagnosticsSectionProps = {
|
const diagnosticsProps: DiagnosticsSectionProps = {
|
||||||
@@ -1130,7 +1092,7 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||||
hostsProps,
|
hostsProps,
|
||||||
generalProps,
|
appearanceProps,
|
||||||
providersProps,
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
@@ -1865,20 +1827,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
themeTrigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
themeTriggerText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
disabled: {
|
disabled: {
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
@@ -1953,11 +1901,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
audioRowSubtitle: {
|
|
||||||
color: theme.colors.mutedForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
providerActions: {
|
providerActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -38,28 +38,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
paddingTop: theme.spacing[16],
|
paddingTop: theme.spacing[16],
|
||||||
},
|
},
|
||||||
errorScreen: {
|
|
||||||
position: "relative",
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
errorScrollView: {
|
|
||||||
flex: 1,
|
|
||||||
...(Platform.OS === "web"
|
|
||||||
? {
|
|
||||||
overflowX: "auto",
|
|
||||||
overflowY: "auto",
|
|
||||||
}
|
|
||||||
: null),
|
|
||||||
},
|
|
||||||
errorScrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
paddingHorizontal: theme.spacing[8],
|
|
||||||
paddingVertical: theme.spacing[8],
|
|
||||||
paddingTop: theme.spacing[16],
|
|
||||||
},
|
|
||||||
centeredContent: {
|
centeredContent: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -277,73 +255,67 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.errorScreen}>
|
<View style={[styles.container, styles.containerError]}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<ScrollView
|
<View style={styles.errorContent}>
|
||||||
style={styles.errorScrollView}
|
<View style={styles.errorHeader}>
|
||||||
contentContainerStyle={styles.errorScrollContent}
|
<PaseoLogo size={64} />
|
||||||
showsVerticalScrollIndicator
|
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||||
>
|
|
||||||
<View style={styles.errorContent}>
|
|
||||||
<View style={styles.errorHeader}>
|
|
||||||
<PaseoLogo size={64} />
|
|
||||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.errorDescription}>
|
|
||||||
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={styles.errorMessage}>
|
|
||||||
{bootstrapState.error}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
|
||||||
|
|
||||||
<View style={styles.logsContainer}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.logsScroll}
|
|
||||||
contentContainerStyle={styles.logsContent}
|
|
||||||
showsVerticalScrollIndicator
|
|
||||||
>
|
|
||||||
<Text selectable style={styles.logsText}>
|
|
||||||
{logsText}
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.actionRow}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleCopyLogs}
|
|
||||||
>
|
|
||||||
Copy logs
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
|
||||||
>
|
|
||||||
Open GitHub issue
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
|
||||||
onPress={bootstrapState.retry}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
<Text style={styles.errorDescription}>
|
||||||
|
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.errorMessage}>
|
||||||
|
{bootstrapState.error}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.logsContainer}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.logsScroll}
|
||||||
|
contentContainerStyle={styles.logsContent}
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
>
|
||||||
|
<Text selectable style={styles.logsText}>
|
||||||
|
{logsText}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleCopyLogs}
|
||||||
|
>
|
||||||
|
Copy logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||||
|
>
|
||||||
|
Open GitHub issue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
||||||
|
onPress={bootstrapState.retry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
invalidateProviderModels,
|
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
} = useAgentFormState({
|
} = useAgentFormState({
|
||||||
initialServerId: serverId,
|
initialServerId: serverId,
|
||||||
@@ -374,7 +373,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
features: draftFeatures,
|
features: draftFeatures,
|
||||||
onSetFeature: handleSetFeatureWithFocus,
|
onSetFeature: handleSetFeatureWithFocus,
|
||||||
onDropdownClose: () => focusInputRef.current?.(),
|
onDropdownClose: () => focusInputRef.current?.(),
|
||||||
onModelSelectorOpen: invalidateProviderModels,
|
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import invariant from "tiny-invariant";
|
|||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
|
||||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import {
|
import {
|
||||||
@@ -81,7 +80,6 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
|
|||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
@@ -118,7 +116,7 @@ import {
|
|||||||
closeBulkWorkspaceTabs,
|
closeBulkWorkspaceTabs,
|
||||||
} from "@/screens/workspace/workspace-bulk-close";
|
} from "@/screens/workspace/workspace-bulk-close";
|
||||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
@@ -593,7 +591,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
|
||||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||||
@@ -761,24 +759,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const {
|
|
||||||
branchOptions,
|
|
||||||
isOpen: isBranchSwitcherOpen,
|
|
||||||
setIsOpen: setIsBranchSwitcherOpen,
|
|
||||||
handleBranchSelect,
|
|
||||||
invalidateStashAndCheckout,
|
|
||||||
} = useBranchSwitcher({
|
|
||||||
client,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
currentBranchName,
|
|
||||||
isGitCheckout,
|
|
||||||
isConnected,
|
|
||||||
toast,
|
|
||||||
queryClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
@@ -1968,14 +1948,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<BranchSwitcher
|
<Text
|
||||||
currentBranchName={currentBranchName}
|
testID="workspace-header-title"
|
||||||
title={workspaceHeader.title}
|
style={styles.headerTitle}
|
||||||
branchOptions={branchOptions}
|
numberOfLines={1}
|
||||||
isOpen={isBranchSwitcherOpen}
|
>
|
||||||
onOpenChange={setIsBranchSwitcherOpen}
|
{workspaceHeader.title}
|
||||||
onBranchSelect={handleBranchSelect}
|
</Text>
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
testID="workspace-header-subtitle"
|
testID="workspace-header-subtitle"
|
||||||
style={styles.headerProjectTitle}
|
style={styles.headerProjectTitle}
|
||||||
@@ -2371,12 +2350,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffStatAdditions: {
|
diffStatAdditions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
diffStatDeletions: {
|
diffStatDeletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.palette.red[500],
|
||||||
},
|
},
|
||||||
newTabActions: {
|
newTabActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -103,19 +103,6 @@ export const baseColors = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
|
|
||||||
|
|
||||||
// Diff stat colors — light uses muted tones, dark uses the brighter palette values
|
|
||||||
const lightDiffColors = {
|
|
||||||
diffAddition: "#15803d", // green-700 — readable on white without screaming
|
|
||||||
diffDeletion: "#b91c1c", // red-700
|
|
||||||
};
|
|
||||||
|
|
||||||
const darkDiffColors = {
|
|
||||||
diffAddition: "#4ade80", // green-400
|
|
||||||
diffDeletion: "#ef4444", // red-500
|
|
||||||
};
|
|
||||||
|
|
||||||
// Semantic color tokens - Layer-based system
|
// Semantic color tokens - Layer-based system
|
||||||
const lightSemanticColors = {
|
const lightSemanticColors = {
|
||||||
// Surfaces (layers) - shifted one step lighter
|
// Surfaces (layers) - shifted one step lighter
|
||||||
@@ -126,11 +113,10 @@ const lightSemanticColors = {
|
|||||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||||
surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode)
|
|
||||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
foreground: "#1a1a1e",
|
foreground: "#09090b",
|
||||||
foregroundMuted: "#71717a",
|
foregroundMuted: "#71717a",
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
@@ -154,28 +140,26 @@ const lightSemanticColors = {
|
|||||||
// Legacy aliases (for gradual migration)
|
// Legacy aliases (for gradual migration)
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
popover: "#ffffff",
|
popover: "#ffffff",
|
||||||
popoverForeground: "#1a1a1e",
|
popoverForeground: "#09090b",
|
||||||
primary: "#18181b",
|
primary: "#18181b",
|
||||||
primaryForeground: "#fafafa",
|
primaryForeground: "#fafafa",
|
||||||
secondary: "#f4f4f5",
|
secondary: "#f4f4f5",
|
||||||
secondaryForeground: "#1a1a1e",
|
secondaryForeground: "#09090b",
|
||||||
muted: "#f4f4f5",
|
muted: "#f4f4f5",
|
||||||
mutedForeground: "#71717a",
|
mutedForeground: "#71717a",
|
||||||
accentBorder: "#ececf1",
|
accentBorder: "#ececf1",
|
||||||
input: "#f4f4f5",
|
input: "#f4f4f5",
|
||||||
ring: "#18181b",
|
ring: "#18181b",
|
||||||
|
|
||||||
...lightDiffColors,
|
|
||||||
|
|
||||||
terminal: {
|
terminal: {
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
foreground: "#1a1a1e",
|
foreground: "#09090b",
|
||||||
cursor: "#1a1a1e",
|
cursor: "#09090b",
|
||||||
cursorAccent: "#ffffff",
|
cursorAccent: "#ffffff",
|
||||||
selectionBackground: "rgba(0, 0, 0, 0.15)",
|
selectionBackground: "rgba(0, 0, 0, 0.15)",
|
||||||
selectionForeground: "#1a1a1e",
|
selectionForeground: "#09090b",
|
||||||
|
|
||||||
black: "#1a1a1e",
|
black: "#09090b",
|
||||||
red: "#dc2626",
|
red: "#dc2626",
|
||||||
green: "#16a34a",
|
green: "#16a34a",
|
||||||
yellow: "#ca8a04",
|
yellow: "#ca8a04",
|
||||||
@@ -195,196 +179,80 @@ const lightSemanticColors = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const darkSemanticColors = {
|
||||||
// Dark theme variant builder
|
// Surfaces (layers) — subtle teal tint
|
||||||
// ---------------------------------------------------------------------------
|
surface0: "#181B1A", // App background
|
||||||
|
surface1: "#1E2120", // Subtle hover
|
||||||
|
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||||
|
surface3: "#434645", // Highest elevation
|
||||||
|
surface4: "#595B5B", // Extra emphasis
|
||||||
|
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||||
|
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||||
|
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||||
|
|
||||||
interface DarkThemeConfig {
|
// Text
|
||||||
surface0: string;
|
foreground: "#fafafa",
|
||||||
surface1: string;
|
|
||||||
surface2: string;
|
|
||||||
surface3: string;
|
|
||||||
surface4: string;
|
|
||||||
surfaceDiffEmpty: string;
|
|
||||||
surfaceSidebar: string;
|
|
||||||
surfaceSidebarHover: string;
|
|
||||||
foregroundMuted: string;
|
|
||||||
scrollbarHandle: string;
|
|
||||||
border: string;
|
|
||||||
borderAccent: string;
|
|
||||||
accent: string;
|
|
||||||
accentBright: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkTerminalAnsi = {
|
|
||||||
red: "#e07070",
|
|
||||||
green: "#5dba80",
|
|
||||||
yellow: "#d4a44a",
|
|
||||||
blue: "#6a9de0",
|
|
||||||
magenta: "#b07ad0",
|
|
||||||
cyan: "#4aabb8",
|
|
||||||
white: "#d4d4d8",
|
|
||||||
brightRed: "#e89090",
|
|
||||||
brightGreen: "#7ecf9a",
|
|
||||||
brightYellow: "#e0be6e",
|
|
||||||
brightBlue: "#8ab4e8",
|
|
||||||
brightMagenta: "#c49ae0",
|
|
||||||
brightCyan: "#6ec2cc",
|
|
||||||
brightWhite: "#f0f0f2",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
|
||||||
return {
|
|
||||||
surface0: tint.surface0,
|
|
||||||
surface1: tint.surface1,
|
|
||||||
surface2: tint.surface2,
|
|
||||||
surface3: tint.surface3,
|
|
||||||
surface4: tint.surface4,
|
|
||||||
surfaceDiffEmpty: tint.surfaceDiffEmpty,
|
|
||||||
surfaceSidebar: tint.surfaceSidebar,
|
|
||||||
surfaceSidebarHover: tint.surfaceSidebarHover,
|
|
||||||
surfaceWorkspace: tint.surface1,
|
|
||||||
|
|
||||||
foreground: "#fafafa",
|
|
||||||
foregroundMuted: tint.foregroundMuted,
|
|
||||||
|
|
||||||
scrollbarHandle: tint.scrollbarHandle,
|
|
||||||
|
|
||||||
border: tint.border,
|
|
||||||
borderAccent: tint.borderAccent,
|
|
||||||
|
|
||||||
accent: tint.accent,
|
|
||||||
accentBright: tint.accentBright,
|
|
||||||
accentForeground: "#ffffff",
|
|
||||||
|
|
||||||
destructive: "#ef4444",
|
|
||||||
destructiveForeground: "#ffffff",
|
|
||||||
success: tint.accent,
|
|
||||||
successForeground: "#ffffff",
|
|
||||||
|
|
||||||
// Legacy aliases (for gradual migration)
|
|
||||||
background: tint.surface0,
|
|
||||||
popover: tint.surface2,
|
|
||||||
popoverForeground: "#fafafa",
|
|
||||||
primary: "#fafafa",
|
|
||||||
primaryForeground: tint.surface0,
|
|
||||||
secondary: tint.surface2,
|
|
||||||
secondaryForeground: "#fafafa",
|
|
||||||
muted: tint.surface2,
|
|
||||||
mutedForeground: tint.foregroundMuted,
|
|
||||||
accentBorder: tint.borderAccent,
|
|
||||||
input: tint.surface2,
|
|
||||||
ring: "#d4d4d8",
|
|
||||||
|
|
||||||
...darkDiffColors,
|
|
||||||
|
|
||||||
terminal: {
|
|
||||||
background: tint.surface0,
|
|
||||||
foreground: "#fafafa",
|
|
||||||
cursor: "#fafafa",
|
|
||||||
cursorAccent: tint.surface0,
|
|
||||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
|
||||||
selectionForeground: "#fafafa",
|
|
||||||
black: tint.surfaceSidebar,
|
|
||||||
...darkTerminalAnsi,
|
|
||||||
brightBlack: tint.surface3,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Dark tint definitions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Paseo — subtle teal-green tint (default)
|
|
||||||
const paseoDarkColors = buildDarkSemanticColors({
|
|
||||||
surface0: "#181B1A",
|
|
||||||
surface1: "#1E2120",
|
|
||||||
surface2: "#272A29",
|
|
||||||
surface3: "#434645",
|
|
||||||
surface4: "#595B5B",
|
|
||||||
surfaceDiffEmpty: "#252827",
|
|
||||||
surfaceSidebar: "#141716",
|
|
||||||
surfaceSidebarHover: "#1c1f1e",
|
|
||||||
foregroundMuted: "#A1A5A4",
|
foregroundMuted: "#A1A5A4",
|
||||||
scrollbarHandle: "#717574",
|
|
||||||
|
// Controls
|
||||||
|
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
|
||||||
|
|
||||||
|
// Borders
|
||||||
border: "#252B2A",
|
border: "#252B2A",
|
||||||
borderAccent: "#2F3534",
|
borderAccent: "#2F3534",
|
||||||
|
|
||||||
|
// Brand
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
accentBright: "#7ccba0",
|
accentBright: "#7ccba0",
|
||||||
});
|
accentForeground: "#ffffff",
|
||||||
|
|
||||||
// Zinc — neutral gray, no tint
|
// Semantic
|
||||||
const zincDarkColors = buildDarkSemanticColors({
|
destructive: "#ef4444",
|
||||||
surface0: "#18181b",
|
destructiveForeground: "#ffffff",
|
||||||
surface1: "#1f1f22",
|
success: "#20744A",
|
||||||
surface2: "#27272a",
|
successForeground: "#ffffff",
|
||||||
surface3: "#3f3f46",
|
|
||||||
surface4: "#52525b",
|
|
||||||
surfaceDiffEmpty: "#242427",
|
|
||||||
surfaceSidebar: "#131316",
|
|
||||||
surfaceSidebarHover: "#1b1b1e",
|
|
||||||
foregroundMuted: "#a1a1aa",
|
|
||||||
scrollbarHandle: "#71717a",
|
|
||||||
border: "#27272a",
|
|
||||||
borderAccent: "#303036",
|
|
||||||
accent: "#20744A",
|
|
||||||
accentBright: "#7ccba0",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Midnight — subtle blue tint
|
// Legacy aliases (for gradual migration)
|
||||||
const midnightDarkColors = buildDarkSemanticColors({
|
background: "#181B1A",
|
||||||
surface0: "#161820",
|
popover: "#272A29",
|
||||||
surface1: "#1c1e27",
|
popoverForeground: "#fafafa",
|
||||||
surface2: "#252731",
|
primary: "#fafafa",
|
||||||
surface3: "#3c3e4c",
|
primaryForeground: "#181B1A",
|
||||||
surface4: "#535564",
|
secondary: "#272A29",
|
||||||
surfaceDiffEmpty: "#222430",
|
secondaryForeground: "#fafafa",
|
||||||
surfaceSidebar: "#121420",
|
muted: "#272A29",
|
||||||
surfaceSidebarHover: "#1a1c28",
|
mutedForeground: "#A1A5A4",
|
||||||
foregroundMuted: "#9a9db0",
|
accentBorder: "#2F3534",
|
||||||
scrollbarHandle: "#6b6e82",
|
input: "#272A29",
|
||||||
border: "#242636",
|
ring: "#d4d4d8",
|
||||||
borderAccent: "#2e3040",
|
|
||||||
accent: "#3b6fcf",
|
|
||||||
accentBright: "#7eaaeb",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Claude — warm neutral with subtle orange undertone
|
terminal: {
|
||||||
const claudeDarkColors = buildDarkSemanticColors({
|
background: "#181B1A",
|
||||||
surface0: "#1f1f1e",
|
foreground: "#fafafa",
|
||||||
surface1: "#262523",
|
cursor: "#fafafa",
|
||||||
surface2: "#2f2d2b",
|
cursorAccent: "#181B1A",
|
||||||
surface3: "#4a4745",
|
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||||
surface4: "#605d5b",
|
selectionForeground: "#fafafa",
|
||||||
surfaceDiffEmpty: "#2a2826",
|
|
||||||
surfaceSidebar: "#1a1918",
|
|
||||||
surfaceSidebarHover: "#222120",
|
|
||||||
foregroundMuted: "#ada9a5",
|
|
||||||
scrollbarHandle: "#78746f",
|
|
||||||
border: "#2c2a27",
|
|
||||||
borderAccent: "#36332f",
|
|
||||||
accent: "#d97757",
|
|
||||||
accentBright: "#e89a7f",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ghostty — blue-tinted dark based on Ghostty default background
|
black: "#141716",
|
||||||
const ghosttyDarkColors = buildDarkSemanticColors({
|
red: "#ef4444",
|
||||||
surface0: "#282c34",
|
green: "#22c55e",
|
||||||
surface1: "#2f333d",
|
yellow: "#f59e0b",
|
||||||
surface2: "#383c48",
|
blue: "#3b82f6",
|
||||||
surface3: "#4a4f5e",
|
magenta: "#a855f7",
|
||||||
surface4: "#5b6175",
|
cyan: "#06b6d4",
|
||||||
surfaceDiffEmpty: "#323643",
|
white: "#e4e4e7",
|
||||||
surfaceSidebar: "#21252d",
|
|
||||||
surfaceSidebarHover: "#292d36",
|
brightBlack: "#434645",
|
||||||
foregroundMuted: "#c8ccd8",
|
brightRed: "#f87171",
|
||||||
scrollbarHandle: "#a0a4b2",
|
brightGreen: "#4ade80",
|
||||||
border: "#353a47",
|
brightYellow: "#fbbf24",
|
||||||
borderAccent: "#3f4454",
|
brightBlue: "#60a5fa",
|
||||||
accent: "#89b4fa",
|
brightMagenta: "#c084fc",
|
||||||
accentBright: "#b4d0fc",
|
brightCyan: "#22d3ee",
|
||||||
});
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const commonTheme = {
|
const commonTheme = {
|
||||||
spacing: {
|
spacing: {
|
||||||
@@ -455,44 +323,34 @@ const commonTheme = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const darkShadow = {
|
export const darkTheme = {
|
||||||
sm: {
|
colorScheme: "dark" as const,
|
||||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
colors: {
|
||||||
shadowOffset: { width: 0, height: 2 },
|
...darkSemanticColors,
|
||||||
shadowRadius: 4,
|
palette: baseColors,
|
||||||
elevation: 2,
|
|
||||||
},
|
},
|
||||||
md: {
|
shadow: {
|
||||||
shadowColor: "rgba(0, 0, 0, 0.20)",
|
sm: {
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||||
shadowRadius: 8,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
elevation: 8,
|
shadowRadius: 4,
|
||||||
},
|
elevation: 2,
|
||||||
lg: {
|
|
||||||
shadowColor: "rgba(0, 0, 0, 0.40)",
|
|
||||||
shadowOffset: { width: 0, height: 12 },
|
|
||||||
shadowRadius: 24,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
|
|
||||||
return {
|
|
||||||
colorScheme: "dark" as const,
|
|
||||||
colors: {
|
|
||||||
...semanticColors,
|
|
||||||
palette: baseColors,
|
|
||||||
},
|
},
|
||||||
shadow: darkShadow,
|
md: {
|
||||||
...commonTheme,
|
shadowColor: "rgba(0, 0, 0, 0.20)",
|
||||||
} as const;
|
shadowOffset: { width: 0, height: 4 },
|
||||||
}
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
export const darkTheme = buildDarkTheme(paseoDarkColors);
|
},
|
||||||
export const darkZincTheme = buildDarkTheme(zincDarkColors);
|
lg: {
|
||||||
export const darkMidnightTheme = buildDarkTheme(midnightDarkColors);
|
shadowColor: "rgba(0, 0, 0, 0.40)",
|
||||||
export const darkClaudeTheme = buildDarkTheme(claudeDarkColors);
|
shadowOffset: { width: 0, height: 12 },
|
||||||
export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors);
|
shadowRadius: 24,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...commonTheme,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const lightTheme = {
|
export const lightTheme = {
|
||||||
colorScheme: "light" as const,
|
colorScheme: "light" as const,
|
||||||
@@ -528,23 +386,3 @@ export const theme = darkTheme;
|
|||||||
|
|
||||||
// Export a union type that works for both themes
|
// Export a union type that works for both themes
|
||||||
export type Theme = typeof darkTheme | typeof lightTheme;
|
export type Theme = typeof darkTheme | typeof lightTheme;
|
||||||
|
|
||||||
type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty";
|
|
||||||
|
|
||||||
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
|
|
||||||
light: "light",
|
|
||||||
dark: "dark",
|
|
||||||
zinc: "darkZinc",
|
|
||||||
midnight: "darkMidnight",
|
|
||||||
claude: "darkClaude",
|
|
||||||
ghostty: "darkGhostty",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const THEME_SWATCHES: Record<ThemeName, string> = {
|
|
||||||
light: "#ffffff",
|
|
||||||
dark: "#2D8B62",
|
|
||||||
zinc: "#808080",
|
|
||||||
midnight: "#4A6BA8",
|
|
||||||
claude: "#D97757",
|
|
||||||
ghostty: "#8caaee",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import {
|
// import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
lightTheme,
|
import { lightTheme, darkTheme } from "./theme";
|
||||||
darkTheme,
|
|
||||||
darkZincTheme,
|
|
||||||
darkMidnightTheme,
|
|
||||||
darkClaudeTheme,
|
|
||||||
darkGhosttyTheme,
|
|
||||||
} from "./theme";
|
|
||||||
|
|
||||||
StyleSheet.configure({
|
StyleSheet.configure({
|
||||||
themes: {
|
themes: {
|
||||||
light: lightTheme,
|
light: lightTheme,
|
||||||
dark: darkTheme,
|
dark: darkTheme,
|
||||||
darkZinc: darkZincTheme,
|
|
||||||
darkMidnight: darkMidnightTheme,
|
|
||||||
darkClaude: darkClaudeTheme,
|
|
||||||
darkGhostty: darkGhosttyTheme,
|
|
||||||
},
|
},
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
xs: 0,
|
xs: 0,
|
||||||
@@ -33,10 +23,6 @@ StyleSheet.configure({
|
|||||||
type AppThemes = {
|
type AppThemes = {
|
||||||
light: typeof lightTheme;
|
light: typeof lightTheme;
|
||||||
dark: typeof darkTheme;
|
dark: typeof darkTheme;
|
||||||
darkZinc: typeof darkZincTheme;
|
|
||||||
darkMidnight: typeof darkMidnightTheme;
|
|
||||||
darkClaude: typeof darkClaudeTheme;
|
|
||||||
darkGhostty: typeof darkGhosttyTheme;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppBreakpoints = {
|
type AppBreakpoints = {
|
||||||
@@ -51,3 +37,5 @@ declare module "react-native-unistyles" {
|
|||||||
export interface UnistylesThemes extends AppThemes {}
|
export interface UnistylesThemes extends AppThemes {}
|
||||||
export interface UnistylesBreakpoints extends AppBreakpoints {}
|
export interface UnistylesBreakpoints extends AppBreakpoints {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout";
|
import { buildSplitDiffRows } from "./diff-layout";
|
||||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||||
|
|
||||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||||
@@ -79,76 +79,3 @@ describe("buildSplitDiffRows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildUnifiedDiffLines", () => {
|
|
||||||
it("computes line numbers per line type within a hunk", () => {
|
|
||||||
const lines = buildUnifiedDiffLines(
|
|
||||||
makeFile([
|
|
||||||
{ type: "header", content: "@@ -10,3 +10,4 @@" },
|
|
||||||
{ type: "context", content: "before" },
|
|
||||||
{ type: "add", content: "inserted" },
|
|
||||||
{ type: "remove", content: "removed" },
|
|
||||||
{ type: "context", content: "after" },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
lines.map(({ line, lineNumber }) => ({
|
|
||||||
type: line.type,
|
|
||||||
lineNumber,
|
|
||||||
content: line.content,
|
|
||||||
})),
|
|
||||||
).toEqual([
|
|
||||||
{ type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" },
|
|
||||||
{ type: "context", lineNumber: 10, content: "before" },
|
|
||||||
{ type: "add", lineNumber: 11, content: "inserted" },
|
|
||||||
{ type: "remove", lineNumber: 11, content: "removed" },
|
|
||||||
{ type: "context", lineNumber: 12, content: "after" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restarts numbering at each hunk boundary", () => {
|
|
||||||
const file: ParsedDiffFile = {
|
|
||||||
path: "example.ts",
|
|
||||||
isNew: false,
|
|
||||||
isDeleted: false,
|
|
||||||
additions: 1,
|
|
||||||
deletions: 0,
|
|
||||||
status: "ok",
|
|
||||||
hunks: [
|
|
||||||
{
|
|
||||||
oldStart: 75,
|
|
||||||
oldCount: 2,
|
|
||||||
newStart: 75,
|
|
||||||
newCount: 3,
|
|
||||||
lines: [
|
|
||||||
{ type: "header", content: "@@ -75,2 +75,3 @@" },
|
|
||||||
{ type: "context", content: "first" },
|
|
||||||
{ type: "add", content: "inserted" },
|
|
||||||
{ type: "context", content: "second" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
oldStart: 165,
|
|
||||||
oldCount: 2,
|
|
||||||
newStart: 166,
|
|
||||||
newCount: 2,
|
|
||||||
lines: [
|
|
||||||
{ type: "header", content: "@@ -165,2 +166,2 @@" },
|
|
||||||
{ type: "context", content: "third" },
|
|
||||||
{ type: "context", content: "fourth" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines = buildUnifiedDiffLines(file);
|
|
||||||
|
|
||||||
expect(lines[0]?.lineNumber).toBeNull();
|
|
||||||
expect(lines[1]?.lineNumber).toBe(75);
|
|
||||||
expect(lines[3]?.lineNumber).toBe(77);
|
|
||||||
expect(lines[4]?.lineNumber).toBeNull();
|
|
||||||
expect(lines[5]?.lineNumber).toBe(166);
|
|
||||||
expect(lines[6]?.lineNumber).toBe(167);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ export interface SplitDiffDisplayLine {
|
|||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedDiffDisplayLine {
|
|
||||||
key: string;
|
|
||||||
line: DiffLine;
|
|
||||||
lineNumber: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SplitDiffRow =
|
export type SplitDiffRow =
|
||||||
| {
|
| {
|
||||||
kind: "header";
|
kind: "header";
|
||||||
@@ -67,39 +61,6 @@ function toDisplayLine(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
|
|
||||||
const lines: UnifiedDiffDisplayLine[] = [];
|
|
||||||
|
|
||||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
|
||||||
let oldLineNo = hunk.oldStart;
|
|
||||||
let newLineNo = hunk.newStart;
|
|
||||||
|
|
||||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
|
||||||
let lineNumber: number | null = null;
|
|
||||||
|
|
||||||
if (line.type === "remove") {
|
|
||||||
lineNumber = oldLineNo;
|
|
||||||
oldLineNo += 1;
|
|
||||||
} else if (line.type === "add") {
|
|
||||||
lineNumber = newLineNo;
|
|
||||||
newLineNo += 1;
|
|
||||||
} else if (line.type === "context") {
|
|
||||||
lineNumber = newLineNo;
|
|
||||||
oldLineNo += 1;
|
|
||||||
newLineNo += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push({
|
|
||||||
key: `${hunkIndex}-${lineIndex}`,
|
|
||||||
line,
|
|
||||||
lineNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||||
const rows: SplitDiffRow[] = [];
|
const rows: SplitDiffRow[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering";
|
|
||||||
|
|
||||||
describe("diff-rendering", () => {
|
|
||||||
it("keeps header gutters tall even when they do not show a line number", () => {
|
|
||||||
expect(formatDiffGutterText(null)).toBe(" ");
|
|
||||||
expect(formatDiffGutterText(82)).toBe("82");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps empty split cells tall even when they have no visible content", () => {
|
|
||||||
expect(formatDiffContentText(undefined)).toBe(" ");
|
|
||||||
expect(formatDiffContentText("")).toBe(" ");
|
|
||||||
expect(formatDiffContentText("const value = 1;")).toBe("const value = 1;");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats empty highlighted token rows as blank lines instead of visible content", () => {
|
|
||||||
expect(hasVisibleDiffTokens(undefined)).toBe(false);
|
|
||||||
expect(hasVisibleDiffTokens([])).toBe(false);
|
|
||||||
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
|
|
||||||
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
interface HighlightLikeToken {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve row height when a gutter or diff cell is intentionally blank.
|
|
||||||
export function formatDiffGutterText(lineNumber: number | null): string {
|
|
||||||
return lineNumber == null ? " " : String(lineNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDiffContentText(content: string | null | undefined): string {
|
|
||||||
return content && content.length > 0 ? content : " ";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasVisibleDiffTokens(tokens: HighlightLikeToken[] | null | undefined): boolean {
|
|
||||||
return Boolean(tokens?.some((token) => token.text.length > 0));
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
).toBe("attention");
|
).toBe("attention");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats initializing agents as done", () => {
|
it("treats initializing agents as running", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveSidebarStateBucket({
|
deriveSidebarStateBucket({
|
||||||
status: "initializing",
|
status: "initializing",
|
||||||
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
}),
|
}),
|
||||||
).toBe("done");
|
).toBe("running");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: {
|
|||||||
if (input.status === "error" || input.attentionReason === "error") {
|
if (input.status === "error" || input.attentionReason === "error") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (input.status === "running") {
|
if (input.status === "running" || input.status === "initializing") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (input.requiresAttention) {
|
if (input.requiresAttention) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe("terminal key helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only intercepts when pending modifiers are active", () => {
|
it("intercepts special keys and modifier combos", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldInterceptDomTerminalKey({
|
shouldInterceptDomTerminalKey({
|
||||||
key: "Escape",
|
key: "Escape",
|
||||||
@@ -60,7 +60,7 @@ describe("terminal key helpers", () => {
|
|||||||
altKey: false,
|
altKey: false,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
shouldInterceptDomTerminalKey({
|
shouldInterceptDomTerminalKey({
|
||||||
key: "c",
|
key: "c",
|
||||||
@@ -68,23 +68,15 @@ describe("terminal key helpers", () => {
|
|||||||
altKey: false,
|
altKey: false,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
shouldInterceptDomTerminalKey({
|
shouldInterceptDomTerminalKey({
|
||||||
key: "c",
|
key: "c",
|
||||||
ctrlKey: false,
|
ctrlKey: false,
|
||||||
altKey: false,
|
altKey: false,
|
||||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(false);
|
||||||
expect(
|
|
||||||
shouldInterceptDomTerminalKey({
|
|
||||||
key: "Escape",
|
|
||||||
ctrlKey: false,
|
|
||||||
altKey: false,
|
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: true },
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects pending modifier state", () => {
|
it("detects pending modifier state", () => {
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
altKey: boolean;
|
altKey: boolean;
|
||||||
pendingModifiers: PendingTerminalModifiers;
|
pendingModifiers: PendingTerminalModifiers;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return hasPendingTerminalModifiers(args.pendingModifiers);
|
return (
|
||||||
|
args.key.length > 1 ||
|
||||||
|
args.ctrlKey ||
|
||||||
|
args.altKey ||
|
||||||
|
hasPendingTerminalModifiers(args.pendingModifiers)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeTerminalModifiers(args: {
|
export function mergeTerminalModifiers(args: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.52",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||||
"build:main": "tsc -p tsconfig.json",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.52",
|
"@getpaseo/cli": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.52",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
# Build the Electron main process
|
|
||||||
npm run build:main
|
|
||||||
|
|
||||||
# Get a random available port for Metro
|
|
||||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
|
||||||
export EXPO_PORT
|
|
||||||
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
|
||||||
echo " Paseo Desktop Dev"
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
|
||||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
|
||||||
|
|
||||||
# Launch Metro + Electron together, kill both on exit
|
|
||||||
"$ROOT_DIR/node_modules/.bin/concurrently" \
|
|
||||||
--kill-others \
|
|
||||||
--names "metro,electron" \
|
|
||||||
--prefix-colors "magenta,cyan" \
|
|
||||||
"cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \
|
|
||||||
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
|
|
||||||
@@ -7,7 +7,6 @@ import { autoUpdater, type UpdateInfo } from "electron-updater";
|
|||||||
|
|
||||||
export type AppUpdateCheckResult = {
|
export type AppUpdateCheckResult = {
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
readyToInstall: boolean;
|
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
body: string | null;
|
body: string | null;
|
||||||
@@ -25,84 +24,19 @@ export type AppUpdateInstallResult = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let cachedUpdateInfo: UpdateInfo | null = null;
|
let cachedUpdateInfo: UpdateInfo | null = null;
|
||||||
let downloadedUpdateVersion: string | null = null;
|
|
||||||
let downloading = false;
|
let downloading = false;
|
||||||
let autoUpdaterConfigured = false;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configuration
|
// Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function configureAutoUpdater(): void {
|
function configureAutoUpdater(): void {
|
||||||
// Download updates in the background and only prompt once they are ready to install.
|
// Don't auto-download — the user triggers install explicitly.
|
||||||
autoUpdater.autoDownload = true;
|
autoUpdater.autoDownload = false;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
// Suppress built-in dialogs; the renderer handles UI.
|
// Suppress built-in dialogs; the renderer handles UI.
|
||||||
autoUpdater.autoRunAppAfterInstall = true;
|
autoUpdater.autoRunAppAfterInstall = true;
|
||||||
|
|
||||||
if (autoUpdaterConfigured) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
autoUpdaterConfigured = true;
|
|
||||||
|
|
||||||
autoUpdater.on("update-available", (info) => {
|
|
||||||
cachedUpdateInfo = info;
|
|
||||||
downloadedUpdateVersion = null;
|
|
||||||
downloading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
autoUpdater.on("update-downloaded", (info) => {
|
|
||||||
cachedUpdateInfo = info;
|
|
||||||
downloadedUpdateVersion = info.version;
|
|
||||||
downloading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
autoUpdater.on("update-not-available", () => {
|
|
||||||
cachedUpdateInfo = null;
|
|
||||||
downloadedUpdateVersion = null;
|
|
||||||
downloading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
autoUpdater.on("error", (error) => {
|
|
||||||
downloading = false;
|
|
||||||
console.error("[auto-updater] Updater event failed:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReadyToInstallVersion(version: string): boolean {
|
|
||||||
return downloadedUpdateVersion === version;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCheckResult(input: {
|
|
||||||
currentVersion: string;
|
|
||||||
hasUpdate: boolean;
|
|
||||||
readyToInstall: boolean;
|
|
||||||
info?: UpdateInfo | null;
|
|
||||||
}): AppUpdateCheckResult {
|
|
||||||
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasUpdate,
|
|
||||||
readyToInstall,
|
|
||||||
currentVersion,
|
|
||||||
latestVersion: info?.version ?? currentVersion,
|
|
||||||
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
|
||||||
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
|
||||||
// Use a short delay to allow the renderer to receive the response.
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
if (onBeforeQuit) await onBeforeQuit();
|
|
||||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -111,34 +45,28 @@ function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
|||||||
|
|
||||||
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
|
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return buildCheckResult({
|
return {
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
readyToInstall: false,
|
currentVersion,
|
||||||
});
|
latestVersion: currentVersion,
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
configureAutoUpdater();
|
configureAutoUpdater();
|
||||||
|
|
||||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
|
||||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
|
||||||
info: cachedUpdateInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await autoUpdater.checkForUpdates();
|
const result = await autoUpdater.checkForUpdates();
|
||||||
|
|
||||||
if (!result || !result.updateInfo) {
|
if (!result || !result.updateInfo) {
|
||||||
return buildCheckResult({
|
return {
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
readyToInstall: false,
|
currentVersion,
|
||||||
});
|
latestVersion: currentVersion,
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = result.updateInfo;
|
const info = result.updateInfo;
|
||||||
@@ -147,31 +75,24 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
|
|||||||
|
|
||||||
if (hasUpdate) {
|
if (hasUpdate) {
|
||||||
cachedUpdateInfo = info;
|
cachedUpdateInfo = info;
|
||||||
downloading = !isReadyToInstallVersion(latestVersion);
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
|
||||||
info,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedUpdateInfo = null;
|
return {
|
||||||
downloadedUpdateVersion = null;
|
hasUpdate,
|
||||||
downloading = false;
|
|
||||||
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
currentVersion,
|
||||||
hasUpdate: false,
|
latestVersion,
|
||||||
readyToInstall: false,
|
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
|
||||||
});
|
date: typeof info.releaseDate === "string" ? info.releaseDate : null,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[auto-updater] Failed to check for updates:", error);
|
console.error("[auto-updater] Failed to check for updates:", error);
|
||||||
return buildCheckResult({
|
return {
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
readyToInstall: false,
|
currentVersion,
|
||||||
});
|
latestVersion: currentVersion,
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +108,14 @@ export async function downloadAndInstallUpdate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (downloading) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "Update already in progress.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!cachedUpdateInfo) {
|
if (!cachedUpdateInfo) {
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
@@ -197,35 +126,24 @@ export async function downloadAndInstallUpdate(
|
|||||||
|
|
||||||
configureAutoUpdater();
|
configureAutoUpdater();
|
||||||
|
|
||||||
const readyVersion = cachedUpdateInfo.version;
|
|
||||||
if (isReadyToInstallVersion(readyVersion)) {
|
|
||||||
scheduleQuitAndInstall(onBeforeQuit);
|
|
||||||
return {
|
|
||||||
installed: true,
|
|
||||||
version: readyVersion,
|
|
||||||
message: "Update downloaded. The app will restart shortly.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloading) {
|
|
||||||
return {
|
|
||||||
installed: false,
|
|
||||||
version: currentVersion,
|
|
||||||
message: "Update is still being prepared. Try again in a moment.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
downloading = true;
|
downloading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await autoUpdater.downloadUpdate();
|
await autoUpdater.downloadUpdate();
|
||||||
downloadedUpdateVersion = readyVersion;
|
// quitAndInstall restarts the app with the new version.
|
||||||
downloading = false;
|
// Use a short delay to allow the renderer to receive the response.
|
||||||
scheduleQuitAndInstall(onBeforeQuit);
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (onBeforeQuit) await onBeforeQuit();
|
||||||
|
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
version: readyVersion,
|
version: cachedUpdateInfo.version,
|
||||||
message: "Update downloaded. The app will restart shortly.",
|
message: "Update downloaded. The app will restart shortly.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ inheritLoginShellEnv();
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
||||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,37 +38,6 @@ const APP_SCHEME = "paseo";
|
|||||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||||
app.setName("Paseo");
|
app.setName("Paseo");
|
||||||
|
|
||||||
// In dev mode, detect git worktrees and isolate each instance so multiple
|
|
||||||
// Electron windows can run side-by-side (separate userData = separate lock).
|
|
||||||
let devWorktreeName: string | null = null;
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
try {
|
|
||||||
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 3000,
|
|
||||||
}).trim();
|
|
||||||
devWorktreeName = path.basename(topLevel);
|
|
||||||
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
|
|
||||||
const commonDir = path.resolve(
|
|
||||||
topLevel,
|
|
||||||
execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
||||||
cwd: topLevel,
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 3000,
|
|
||||||
}).trim(),
|
|
||||||
);
|
|
||||||
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
|
|
||||||
if (isWorktree) {
|
|
||||||
app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`));
|
|
||||||
log.info("[worktree] isolated userData for worktree:", devWorktreeName);
|
|
||||||
} else {
|
|
||||||
devWorktreeName = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
devWorktreeName = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
||||||
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
||||||
// Must run before app.whenReady().
|
// Must run before app.whenReady().
|
||||||
@@ -159,9 +127,7 @@ async function createMainWindow(): Promise<void> {
|
|||||||
const iconPath = getWindowIconPath();
|
const iconPath = getWindowIconPath();
|
||||||
const systemTheme = resolveSystemWindowTheme();
|
const systemTheme = resolveSystemWindowTheme();
|
||||||
|
|
||||||
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
title,
|
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
@@ -178,10 +144,6 @@ async function createMainWindow(): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (devWorktreeName) {
|
|
||||||
app.dock?.setBadge(devWorktreeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
setupDefaultContextMenu(mainWindow);
|
setupDefaultContextMenu(mainWindow);
|
||||||
setupDragDropPrevention(mainWindow);
|
setupDragDropPrevention(mainWindow);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"types": "build/index.d.ts",
|
"types": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -18,6 +18,3 @@ PASEO_LISTEN=127.0.0.1:6767
|
|||||||
# Debug recordings (dictation + STT input + TTS output)
|
# Debug recordings (dictation + STT input + TTS output)
|
||||||
# When enabled, recordings are saved under `${cwd}/.debug/recordings/`
|
# When enabled, recordings are saved under `${cwd}/.debug/recordings/`
|
||||||
PASEO_DICTATION_DEBUG=1
|
PASEO_DICTATION_DEBUG=1
|
||||||
|
|
||||||
# Enable verbose Claude SDK stream logging (trace-level per-token logs)
|
|
||||||
PASEO_CLAUDE_DEBUG=1
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.52",
|
"version": "0.1.51-rc.1",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.52",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/relay": "0.1.52",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ import type {
|
|||||||
CheckoutPushResponse,
|
CheckoutPushResponse,
|
||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
CheckoutSwitchBranchResponse,
|
|
||||||
StashSaveResponse,
|
|
||||||
StashPopResponse,
|
|
||||||
StashListResponse,
|
|
||||||
ValidateBranchResponse,
|
ValidateBranchResponse,
|
||||||
BranchSuggestionsResponse,
|
BranchSuggestionsResponse,
|
||||||
DirectorySuggestionsResponse,
|
DirectorySuggestionsResponse,
|
||||||
@@ -221,10 +217,6 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
|||||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
|
||||||
type StashSavePayload = StashSaveResponse["payload"];
|
|
||||||
type StashPopPayload = StashPopResponse["payload"];
|
|
||||||
type StashListPayload = StashListResponse["payload"];
|
|
||||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||||
@@ -2382,74 +2374,6 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkoutSwitchBranch(
|
|
||||||
cwd: string,
|
|
||||||
branch: string,
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<CheckoutSwitchBranchPayload> {
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
|
||||||
requestId,
|
|
||||||
message: {
|
|
||||||
type: "checkout_switch_branch_request",
|
|
||||||
cwd,
|
|
||||||
branch,
|
|
||||||
},
|
|
||||||
responseType: "checkout_switch_branch_response",
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async stashSave(
|
|
||||||
cwd: string,
|
|
||||||
options?: { branch?: string },
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<StashSavePayload> {
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
|
||||||
requestId,
|
|
||||||
message: {
|
|
||||||
type: "stash_save_request",
|
|
||||||
cwd,
|
|
||||||
branch: options?.branch,
|
|
||||||
},
|
|
||||||
responseType: "stash_save_response",
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async stashPop(
|
|
||||||
cwd: string,
|
|
||||||
stashIndex: number,
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<StashPopPayload> {
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
|
||||||
requestId,
|
|
||||||
message: {
|
|
||||||
type: "stash_pop_request",
|
|
||||||
cwd,
|
|
||||||
stashIndex,
|
|
||||||
},
|
|
||||||
responseType: "stash_pop_response",
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async stashList(
|
|
||||||
cwd: string,
|
|
||||||
options?: { paseoOnly?: boolean },
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<StashListPayload> {
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
|
||||||
requestId,
|
|
||||||
message: {
|
|
||||||
type: "stash_list_request",
|
|
||||||
cwd,
|
|
||||||
paseoOnly: options?.paseoOnly,
|
|
||||||
},
|
|
||||||
responseType: "stash_list_response",
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPaseoWorktreeList(
|
async getPaseoWorktreeList(
|
||||||
input: { cwd?: string; repoRoot?: string },
|
input: { cwd?: string; repoRoot?: string },
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
|||||||
@@ -415,39 +415,10 @@ describe("transformPiModels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ACPAgentSession slash commands", () => {
|
describe("ACPAgentSession slash commands", () => {
|
||||||
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
|
test("caches ACP available commands for listCommands", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
|
|
||||||
await expect(session.listCommands()).resolves.toEqual([]);
|
expect(await session.listCommands()).toEqual([]);
|
||||||
});
|
|
||||||
|
|
||||||
test("waits for async available_commands_update when enabled", async () => {
|
|
||||||
const session = new ACPAgentSession(
|
|
||||||
{
|
|
||||||
provider: "pi",
|
|
||||||
cwd: "/tmp/paseo-acp-test",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "pi",
|
|
||||||
logger: createTestLogger(),
|
|
||||||
defaultCommand: ["pi-acp"],
|
|
||||||
defaultModes: [],
|
|
||||||
modelTransformer: transformPiModels,
|
|
||||||
sessionResponseTransformer: transformPiSessionResponse,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
},
|
|
||||||
waitForInitialCommands: true,
|
|
||||||
initialCommandsWaitTimeoutMs: 1500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const listCommandsPromise = session.listCommands();
|
|
||||||
|
|
||||||
(session as any).translateSessionUpdate({
|
(session as any).translateSessionUpdate({
|
||||||
sessionUpdate: "available_commands_update",
|
sessionUpdate: "available_commands_update",
|
||||||
@@ -463,19 +434,6 @@ describe("ACPAgentSession slash commands", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await listCommandsPromise).toEqual([
|
|
||||||
{
|
|
||||||
name: "research_codebase",
|
|
||||||
description: "Search the workspace for relevant files",
|
|
||||||
argumentHint: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "create_plan",
|
|
||||||
description: "Draft a plan for the requested work",
|
|
||||||
argumentHint: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(await session.listCommands()).toEqual([
|
expect(await session.listCommands()).toEqual([
|
||||||
{
|
{
|
||||||
name: "research_codebase",
|
name: "research_codebase",
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ type ACPAgentClientOptions = {
|
|||||||
thinkingOptionId: string,
|
thinkingOptionId: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
capabilities?: AgentCapabilityFlags;
|
capabilities?: AgentCapabilityFlags;
|
||||||
waitForInitialCommands?: boolean;
|
|
||||||
initialCommandsWaitTimeoutMs?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ACPAgentSessionOptions = {
|
type ACPAgentSessionOptions = {
|
||||||
@@ -146,8 +144,6 @@ type ACPAgentSessionOptions = {
|
|||||||
capabilities: AgentCapabilityFlags;
|
capabilities: AgentCapabilityFlags;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
launchEnv?: Record<string, string>;
|
launchEnv?: Record<string, string>;
|
||||||
waitForInitialCommands?: boolean;
|
|
||||||
initialCommandsWaitTimeoutMs?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SpawnedACPProcess = {
|
type SpawnedACPProcess = {
|
||||||
@@ -306,8 +302,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
thinkingOptionId: string,
|
thinkingOptionId: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
private readonly waitForInitialCommands: boolean;
|
|
||||||
private readonly initialCommandsWaitTimeoutMs: number;
|
|
||||||
|
|
||||||
constructor(options: ACPAgentClientOptions) {
|
constructor(options: ACPAgentClientOptions) {
|
||||||
this.provider = options.provider;
|
this.provider = options.provider;
|
||||||
@@ -320,8 +314,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
|
||||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
@@ -343,8 +335,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||||
capabilities: this.capabilities,
|
capabilities: this.capabilities,
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
waitForInitialCommands: this.waitForInitialCommands,
|
|
||||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await session.initializeNewSession();
|
await session.initializeNewSession();
|
||||||
@@ -385,8 +375,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
capabilities: this.capabilities,
|
capabilities: this.capabilities,
|
||||||
handle,
|
handle,
|
||||||
launchEnv: launchContext?.env,
|
launchEnv: launchContext?.env,
|
||||||
waitForInitialCommands: this.waitForInitialCommands,
|
|
||||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
|
||||||
});
|
});
|
||||||
await session.initializeResumedSession();
|
await session.initializeResumedSession();
|
||||||
return session;
|
return session;
|
||||||
@@ -629,10 +617,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
private lastActivityAt: string | null = null;
|
private lastActivityAt: string | null = null;
|
||||||
private configOptions: SessionConfigOption[] = [];
|
private configOptions: SessionConfigOption[] = [];
|
||||||
private cachedCommands: AgentSlashCommand[] = [];
|
private cachedCommands: AgentSlashCommand[] = [];
|
||||||
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
|
|
||||||
private commandsReadySettled = false;
|
|
||||||
private waitForInitialCommands: boolean;
|
|
||||||
private initialCommandsWaitTimeoutMs: number;
|
|
||||||
private currentTurnUsage: AgentUsage | undefined;
|
private currentTurnUsage: AgentUsage | undefined;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
@@ -661,8 +645,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.currentModel = config.model ?? null;
|
this.currentModel = config.model ?? null;
|
||||||
this.thinkingOptionId = config.thinkingOptionId ?? null;
|
this.thinkingOptionId = config.thinkingOptionId ?? null;
|
||||||
this.currentTitle = config.title ?? null;
|
this.currentTitle = config.title ?? null;
|
||||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
|
||||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
@@ -894,59 +876,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
return this.currentMode;
|
return this.currentMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureCommandsReadyDeferred(): void {
|
|
||||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolve!: () => void;
|
|
||||||
const promise = new Promise<void>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
this.commandsReadyDeferred = { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
private settleCommandsReady(): void {
|
|
||||||
if (this.commandsReadySettled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.commandsReadySettled = true;
|
|
||||||
this.commandsReadyDeferred?.resolve();
|
|
||||||
this.commandsReadyDeferred = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForCommandsReady(): Promise<void> {
|
|
||||||
const deferred = this.commandsReadyDeferred;
|
|
||||||
if (!deferred) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
deferred.promise,
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
if (this.cachedCommands.length > 0) {
|
|
||||||
return this.cachedCommands;
|
|
||||||
}
|
|
||||||
if (!this.waitForInitialCommands || this.closed) {
|
|
||||||
return this.cachedCommands;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureCommandsReadyDeferred();
|
|
||||||
await this.waitForCommandsReady();
|
|
||||||
this.settleCommandsReady();
|
|
||||||
return this.cachedCommands;
|
return this.cachedCommands;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,8 +1046,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
|
|
||||||
this.settleCommandsReady();
|
|
||||||
|
|
||||||
for (const pending of this.pendingPermissions.values()) {
|
for (const pending of this.pendingPermissions.values()) {
|
||||||
pending.resolve({ outcome: { outcome: "cancelled" } });
|
pending.resolve({ outcome: { outcome: "cancelled" } });
|
||||||
}
|
}
|
||||||
@@ -1464,7 +1392,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
description: command.description,
|
description: command.description,
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
}));
|
}));
|
||||||
this.settleCommandsReady();
|
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1047,8 +1047,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
|
|
||||||
|
|
||||||
export class ClaudeAgentClient implements AgentClient {
|
export class ClaudeAgentClient implements AgentClient {
|
||||||
readonly provider: "claude" = "claude";
|
readonly provider: "claude" = "claude";
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
@@ -2456,17 +2454,15 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
while (!this.closed && this.query === activeQuery) {
|
while (!this.closed && this.query === activeQuery) {
|
||||||
try {
|
try {
|
||||||
for await (const message of activeQuery) {
|
for await (const message of activeQuery) {
|
||||||
if (claudeDebug) {
|
this.logger.trace(
|
||||||
this.logger.trace(
|
{
|
||||||
{
|
claudeSessionId: this.claudeSessionId,
|
||||||
claudeSessionId: this.claudeSessionId,
|
messageType: message.type,
|
||||||
messageType: message.type,
|
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
||||||
messageSubtype: "subtype" in message ? message.subtype : undefined,
|
messageUuid: "uuid" in message ? message.uuid : undefined,
|
||||||
messageUuid: "uuid" in message ? message.uuid : undefined,
|
},
|
||||||
},
|
"Claude query pump: raw SDK message",
|
||||||
"Claude query pump: raw SDK message",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
consecutiveInterruptAbortRecoveries = 0;
|
consecutiveInterruptAbortRecoveries = 0;
|
||||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||||
return;
|
return;
|
||||||
@@ -2542,16 +2538,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||||
const identifiers = readEventIdentifiers(message);
|
const identifiers = readEventIdentifiers(message);
|
||||||
|
|
||||||
if (claudeDebug) {
|
this.logger.trace(
|
||||||
this.logger.trace(
|
{
|
||||||
{
|
claudeSessionId: this.claudeSessionId,
|
||||||
claudeSessionId: this.claudeSessionId,
|
messageType: message.type,
|
||||||
messageType: message.type,
|
turnId,
|
||||||
turnId,
|
},
|
||||||
},
|
"Claude query pump: SDK message",
|
||||||
"Claude query pump: SDK message",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageEvents = this.translateMessageToEvents(message, {
|
const messageEvents = this.translateMessageToEvents(message, {
|
||||||
suppressAssistantText: true,
|
suppressAssistantText: true,
|
||||||
|
|||||||
@@ -315,23 +315,6 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCode adapter context-window normalization", () => {
|
describe("OpenCode adapter context-window normalization", () => {
|
||||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
|
||||||
expect(
|
|
||||||
__openCodeInternals.buildOpenCodePromptParts([
|
|
||||||
{ type: "text", text: "Describe this image." },
|
|
||||||
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
|
|
||||||
]),
|
|
||||||
).toEqual([
|
|
||||||
{ type: "text", text: "Describe this image." },
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "image/png",
|
|
||||||
filename: "attachment-1.png",
|
|
||||||
url: "data:image/png;base64,YWJjMTIz",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves provider catalog context limit in model metadata", () => {
|
test("preserves provider catalog context limit in model metadata", () => {
|
||||||
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||||
{ id: "openai", name: "OpenAI" },
|
{ id: "openai", name: "OpenAI" },
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type AssistantMessage as OpenCodeAssistantMessage,
|
type AssistantMessage as OpenCodeAssistantMessage,
|
||||||
type Event as OpenCodeEvent,
|
type Event as OpenCodeEvent,
|
||||||
type FilePartInput as OpenCodeFilePartInput,
|
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
type Part as OpenCodePart,
|
type Part as OpenCodePart,
|
||||||
type TextPartInput as OpenCodeTextPartInput,
|
|
||||||
} from "@opencode-ai/sdk/v2/client";
|
} from "@opencode-ai/sdk/v2/client";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -496,66 +494,7 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
|||||||
].some((value) => typeof value === "number" && Number.isFinite(value));
|
].some((value) => typeof value === "number" && Number.isFinite(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenCodeAttachmentExtension(mimeType: string): string {
|
|
||||||
switch (mimeType) {
|
|
||||||
case "image/png":
|
|
||||||
return "png";
|
|
||||||
case "image/jpeg":
|
|
||||||
return "jpg";
|
|
||||||
case "image/webp":
|
|
||||||
return "webp";
|
|
||||||
case "image/gif":
|
|
||||||
return "gif";
|
|
||||||
case "image/svg+xml":
|
|
||||||
return "svg";
|
|
||||||
default:
|
|
||||||
return "bin";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } {
|
|
||||||
const match = data.match(/^data:([^;,]+);base64,(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
mimeType: match[1] ?? mimeType,
|
|
||||||
url: data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mimeType,
|
|
||||||
url: `data:${mimeType};base64,${data}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOpenCodePromptParts(
|
|
||||||
prompt: AgentPromptInput,
|
|
||||||
): Array<OpenCodeTextPartInput | OpenCodeFilePartInput> {
|
|
||||||
if (typeof prompt === "string") {
|
|
||||||
return [{ type: "text", text: prompt }];
|
|
||||||
}
|
|
||||||
let attachmentOrdinal = 0;
|
|
||||||
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
|
|
||||||
for (const part of prompt) {
|
|
||||||
if (part.type === "text") {
|
|
||||||
output.push({ type: "text", text: part.text });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
attachmentOrdinal += 1;
|
|
||||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
|
||||||
output.push({
|
|
||||||
type: "file",
|
|
||||||
mime: normalized.mimeType,
|
|
||||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
|
||||||
normalized.mimeType,
|
|
||||||
)}`,
|
|
||||||
url: normalized.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const __openCodeInternals = {
|
export const __openCodeInternals = {
|
||||||
buildOpenCodePromptParts,
|
|
||||||
buildOpenCodeModelContextWindowLookup,
|
buildOpenCodeModelContextWindowLookup,
|
||||||
buildOpenCodeModelDefinition,
|
buildOpenCodeModelDefinition,
|
||||||
buildOpenCodeModelLookupKey,
|
buildOpenCodeModelLookupKey,
|
||||||
@@ -1497,7 +1436,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.accumulatedUsage =
|
this.accumulatedUsage =
|
||||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||||
|
|
||||||
const parts = buildOpenCodePromptParts(prompt);
|
const parts = this.buildPromptParts(prompt);
|
||||||
const model = this.parseModel(this.config.model);
|
const model = this.parseModel(this.config.model);
|
||||||
const thinkingOptionId = this.config.thinkingOptionId;
|
const thinkingOptionId = this.config.thinkingOptionId;
|
||||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||||
@@ -1923,6 +1862,15 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
|
||||||
|
if (typeof prompt === "string") {
|
||||||
|
return [{ type: "text", text: prompt }];
|
||||||
|
}
|
||||||
|
return prompt
|
||||||
|
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||||
|
.map((p) => ({ type: "text", text: p.text }));
|
||||||
|
}
|
||||||
|
|
||||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||||
|
|||||||
@@ -298,8 +298,6 @@ export class PiACPAgentClient extends ACPAgentClient {
|
|||||||
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
|
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
|
||||||
},
|
},
|
||||||
capabilities: PI_CAPABILITIES,
|
capabilities: PI_CAPABILITIES,
|
||||||
waitForInitialCommands: true,
|
|
||||||
initialCommandsWaitTimeoutMs: 1500,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
|
if (origin && allowedOrigins.has(origin)) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ import {
|
|||||||
} from "./test-utils/index.js";
|
} from "./test-utils/index.js";
|
||||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||||
import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
||||||
import type {
|
|
||||||
AgentClient,
|
|
||||||
AgentPersistenceHandle,
|
|
||||||
AgentRunResult,
|
|
||||||
AgentSession,
|
|
||||||
AgentSessionConfig,
|
|
||||||
AgentStreamEvent,
|
|
||||||
} from "./agent/agent-sdk-types.js";
|
|
||||||
|
|
||||||
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||||
|
|
||||||
@@ -102,116 +94,6 @@ function waitForSignal<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class NonPersistentReloadSession implements AgentSession {
|
|
||||||
readonly provider = "claude" as const;
|
|
||||||
readonly id = null;
|
|
||||||
readonly capabilities = {
|
|
||||||
supportsStreaming: false,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: false,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: false,
|
|
||||||
supportsToolInvocations: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
constructor(private readonly onClose: () => void) {}
|
|
||||||
|
|
||||||
async run(): Promise<AgentRunResult> {
|
|
||||||
return {
|
|
||||||
sessionId: "non-persistent",
|
|
||||||
finalText: "",
|
|
||||||
timeline: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async startTurn(): Promise<{ turnId: string }> {
|
|
||||||
return { turnId: "non-persistent-turn" };
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(_callback: (event: AgentStreamEvent) => void): () => void {
|
|
||||||
return () => undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRuntimeInfo() {
|
|
||||||
return {
|
|
||||||
provider: "claude" as const,
|
|
||||||
sessionId: null,
|
|
||||||
model: null,
|
|
||||||
modeId: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAvailableModes(): Promise<[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCurrentMode(): Promise<string | null> {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setMode(_modeId: string): Promise<void> {}
|
|
||||||
|
|
||||||
getPendingPermissions() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async respondToPermission(): Promise<void> {}
|
|
||||||
|
|
||||||
describePersistence(): AgentPersistenceHandle | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async interrupt(): Promise<void> {}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
|
||||||
this.onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NonPersistentReloadClient implements AgentClient {
|
|
||||||
readonly provider = "claude" as const;
|
|
||||||
readonly capabilities = {
|
|
||||||
supportsStreaming: false,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: false,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: false,
|
|
||||||
supportsToolInvocations: false,
|
|
||||||
} as const;
|
|
||||||
createSessionCalls = 0;
|
|
||||||
resumeSessionCalls = 0;
|
|
||||||
closeCalls = 0;
|
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
|
||||||
this.createSessionCalls += 1;
|
|
||||||
return new NonPersistentReloadSession(() => {
|
|
||||||
this.closeCalls += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeSession(
|
|
||||||
_handle: AgentPersistenceHandle,
|
|
||||||
_overrides?: Partial<AgentSessionConfig>,
|
|
||||||
): Promise<AgentSession> {
|
|
||||||
this.resumeSessionCalls += 1;
|
|
||||||
return new NonPersistentReloadSession(() => {
|
|
||||||
this.closeCalls += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listModels() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("daemon client E2E", () => {
|
describe("daemon client E2E", () => {
|
||||||
let ctx: DaemonTestContext;
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
@@ -397,38 +279,6 @@ describe("daemon client E2E", () => {
|
|||||||
}
|
}
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => {
|
|
||||||
const cwd = tmpCwd();
|
|
||||||
const client = new NonPersistentReloadClient();
|
|
||||||
const localCtx = await createDaemonTestContext({
|
|
||||||
agentClients: {
|
|
||||||
claude: client,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await localCtx.client.createAgent({
|
|
||||||
config: {
|
|
||||||
provider: "claude",
|
|
||||||
cwd,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.createSessionCalls).toBe(1);
|
|
||||||
expect(client.resumeSessionCalls).toBe(0);
|
|
||||||
expect(client.closeCalls).toBe(0);
|
|
||||||
|
|
||||||
await localCtx.client.refreshAgent(created.id);
|
|
||||||
|
|
||||||
expect(client.createSessionCalls).toBe(2);
|
|
||||||
expect(client.resumeSessionCalls).toBe(0);
|
|
||||||
expect(client.closeCalls).toBe(1);
|
|
||||||
} finally {
|
|
||||||
await localCtx.cleanup();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resume_agent auto-unarchives archived agents", async () => {
|
test("resume_agent auto-unarchives archived agents", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
|||||||
|
|
||||||
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
|
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
|
||||||
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
|
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
|
||||||
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
const DEFAULT_FILE_LEVEL: LogLevel = "trace";
|
||||||
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
||||||
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
||||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
|
|||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||||
|
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
|
||||||
|
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
|
||||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||||
|
|
||||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||||
@@ -294,6 +296,7 @@ type ActiveTerminalStream = {
|
|||||||
slot: number;
|
slot: number;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
needsSnapshot: boolean;
|
needsSnapshot: boolean;
|
||||||
|
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionRuntimeMetrics = {
|
export type SessionRuntimeMetrics = {
|
||||||
@@ -405,6 +408,7 @@ export type SessionOptions = {
|
|||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
onMessage: (msg: SessionOutboundMessage) => void;
|
onMessage: (msg: SessionOutboundMessage) => void;
|
||||||
onBinaryMessage?: (frame: Uint8Array) => void;
|
onBinaryMessage?: (frame: Uint8Array) => void;
|
||||||
|
getBinaryBufferedAmount?: () => number;
|
||||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
downloadTokenStore: DownloadTokenStore;
|
downloadTokenStore: DownloadTokenStore;
|
||||||
@@ -559,6 +563,7 @@ export class Session {
|
|||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
||||||
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
||||||
|
private readonly getBinaryBufferedAmount: (() => number) | null;
|
||||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||||
private readonly sessionLogger: pino.Logger;
|
private readonly sessionLogger: pino.Logger;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
@@ -657,6 +662,7 @@ export class Session {
|
|||||||
appVersion,
|
appVersion,
|
||||||
onMessage,
|
onMessage,
|
||||||
onBinaryMessage,
|
onBinaryMessage,
|
||||||
|
getBinaryBufferedAmount,
|
||||||
onLifecycleIntent,
|
onLifecycleIntent,
|
||||||
logger,
|
logger,
|
||||||
downloadTokenStore,
|
downloadTokenStore,
|
||||||
@@ -686,6 +692,7 @@ export class Session {
|
|||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
this.onMessage = onMessage;
|
this.onMessage = onMessage;
|
||||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||||
|
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
|
||||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.pushTokenStore = pushTokenStore;
|
this.pushTokenStore = pushTokenStore;
|
||||||
@@ -1739,22 +1746,6 @@ export class Session {
|
|||||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "checkout_switch_branch_request":
|
|
||||||
await this.handleCheckoutSwitchBranchRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stash_save_request":
|
|
||||||
await this.handleStashSaveRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stash_pop_request":
|
|
||||||
await this.handleStashPopRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stash_list_request":
|
|
||||||
await this.handleStashListRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_commit_request":
|
case "checkout_commit_request":
|
||||||
await this.handleCheckoutCommitRequest(msg);
|
await this.handleCheckoutCommitRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -3104,7 +3095,11 @@ export class Session {
|
|||||||
const existing = this.agentManager.getAgent(agentId);
|
const existing = this.agentManager.getAgent(agentId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.interruptAgentIfRunning(agentId);
|
await this.interruptAgentIfRunning(agentId);
|
||||||
snapshot = await this.agentManager.reloadAgentSession(agentId);
|
if (existing.persistence) {
|
||||||
|
snapshot = await this.agentManager.reloadAgentSession(agentId);
|
||||||
|
} else {
|
||||||
|
snapshot = existing;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const record = await this.agentStorage.get(agentId);
|
const record = await this.agentStorage.get(agentId);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -4436,139 +4431,6 @@ export class Session {
|
|||||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCheckoutSwitchBranchRequest(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { cwd, branch, requestId } = msg;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.checkoutExistingBranch(cwd, branch);
|
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
|
||||||
|
|
||||||
// Push a workspace_update immediately so the sidebar/header reflect
|
|
||||||
// the new branch name without waiting for the background git watcher.
|
|
||||||
await this.emitWorkspaceUpdateForCwd(cwd);
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "checkout_switch_branch_response",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
success: true,
|
|
||||||
branch,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.emit({
|
|
||||||
type: "checkout_switch_branch_response",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
success: false,
|
|
||||||
branch,
|
|
||||||
error: toCheckoutError(error),
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Stash handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
|
||||||
|
|
||||||
private async handleStashSaveRequest(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { cwd, requestId } = msg;
|
|
||||||
try {
|
|
||||||
const branchLabel = msg.branch?.trim() ?? "";
|
|
||||||
const message = branchLabel
|
|
||||||
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
|
||||||
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
|
||||||
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
|
||||||
this.emit({
|
|
||||||
type: "stash_save_response",
|
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.emit({
|
|
||||||
type: "stash_save_response",
|
|
||||||
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStashPopRequest(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { cwd, stashIndex, requestId } = msg;
|
|
||||||
try {
|
|
||||||
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
|
||||||
this.emit({
|
|
||||||
type: "stash_pop_response",
|
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.emit({
|
|
||||||
type: "stash_pop_response",
|
|
||||||
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStashListRequest(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { cwd, requestId } = msg;
|
|
||||||
const paseoOnly = msg.paseoOnly !== false;
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
||||||
const entries: Array<{
|
|
||||||
index: number;
|
|
||||||
message: string;
|
|
||||||
branch: string | null;
|
|
||||||
isPaseo: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const sepIdx = line.indexOf("\0");
|
|
||||||
if (sepIdx < 0) continue;
|
|
||||||
const refPart = line.slice(0, sepIdx);
|
|
||||||
const subject = line.slice(sepIdx + 1);
|
|
||||||
const indexMatch = refPart.match(/\{(\d+)\}/);
|
|
||||||
if (!indexMatch) continue;
|
|
||||||
const index = Number(indexMatch[1]);
|
|
||||||
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
|
|
||||||
const isPaseo = prefixIdx >= 0;
|
|
||||||
const branch = isPaseo
|
|
||||||
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (paseoOnly && !isPaseo) continue;
|
|
||||||
entries.push({ index, message: subject, branch, isPaseo });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "stash_list_response",
|
|
||||||
payload: { cwd, entries, error: null, requestId },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.emit({
|
|
||||||
type: "stash_list_response",
|
|
||||||
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCheckoutCommitRequest(
|
private async handleCheckoutCommitRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -5464,7 +5326,7 @@ export class Session {
|
|||||||
if (agent.status === "error" || agent.attentionReason === "error") {
|
if (agent.status === "error" || agent.attentionReason === "error") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (agent.status === "running") {
|
if (agent.status === "running" || agent.status === "initializing") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (agent.requiresAttention) {
|
if (agent.requiresAttention) {
|
||||||
@@ -8417,6 +8279,7 @@ export class Session {
|
|||||||
slot,
|
slot,
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
needsSnapshot: true,
|
needsSnapshot: true,
|
||||||
|
snapshotRetryTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activeTerminalStreams.set(slot, activeStream);
|
this.activeTerminalStreams.set(slot, activeStream);
|
||||||
@@ -8433,6 +8296,10 @@ export class Session {
|
|||||||
if (activeStream.needsSnapshot || message.data.length === 0) {
|
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||||
|
this.markAllActiveTerminalStreamsForSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.emitBinary(
|
this.emitBinary(
|
||||||
encodeTerminalStreamFrame({
|
encodeTerminalStreamFrame({
|
||||||
opcode: TerminalStreamOpcode.Output,
|
opcode: TerminalStreamOpcode.Output,
|
||||||
@@ -8440,6 +8307,9 @@ export class Session {
|
|||||||
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||||
|
this.markAllActiveTerminalStreamsForSnapshot();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
@@ -8452,6 +8322,21 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
|
||||||
|
if (!activeStream.snapshotRetryTimer) {
|
||||||
|
activeStream.snapshotRetryTimer = setTimeout(() => {
|
||||||
|
activeStream.snapshotRetryTimer = null;
|
||||||
|
this.trySendTerminalSnapshot(activeStream);
|
||||||
|
}, 33);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStream.snapshotRetryTimer) {
|
||||||
|
clearTimeout(activeStream.snapshotRetryTimer);
|
||||||
|
activeStream.snapshotRetryTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
||||||
if (!terminal) {
|
if (!terminal) {
|
||||||
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
|
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
|
||||||
@@ -8468,6 +8353,13 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markAllActiveTerminalStreamsForSnapshot(): void {
|
||||||
|
for (const activeStream of this.activeTerminalStreams.values()) {
|
||||||
|
activeStream.needsSnapshot = true;
|
||||||
|
this.trySendTerminalSnapshot(activeStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private allocateTerminalSlot(): number | null {
|
private allocateTerminalSlot(): number | null {
|
||||||
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
|
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
|
||||||
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
|
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
|
||||||
@@ -8492,6 +8384,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.activeTerminalStreams.delete(slot);
|
this.activeTerminalStreams.delete(slot);
|
||||||
this.terminalIdToSlot.delete(terminalId);
|
this.terminalIdToSlot.delete(terminalId);
|
||||||
|
if (activeStream.snapshotRetryTimer) {
|
||||||
|
clearTimeout(activeStream.snapshotRetryTimer);
|
||||||
|
activeStream.snapshotRetryTimer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
activeStream.unsubscribe();
|
activeStream.unsubscribe();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -8514,4 +8410,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCurrentBinaryBufferedAmount(): number {
|
||||||
|
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
|
||||||
|
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.floor(bufferedAmount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
!!requestHost &&
|
!!requestHost &&
|
||||||
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
|
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
|
||||||
|
|
||||||
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
|
if (!origin || allowedOrigins.has(origin) || sameOrigin) {
|
||||||
callback(true);
|
callback(true);
|
||||||
} else {
|
} else {
|
||||||
this.incrementRuntimeCounter("originRejected");
|
this.incrementRuntimeCounter("originRejected");
|
||||||
@@ -634,6 +634,16 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
this.sendBinaryToConnection(connection, frame);
|
this.sendBinaryToConnection(connection, frame);
|
||||||
},
|
},
|
||||||
|
getBinaryBufferedAmount: () => {
|
||||||
|
if (!connection) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let bufferedAmount = 0;
|
||||||
|
for (const socket of connection.sockets) {
|
||||||
|
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
|
||||||
|
}
|
||||||
|
return bufferedAmount;
|
||||||
|
},
|
||||||
onLifecycleIntent: (intent) => {
|
onLifecycleIntent: (intent) => {
|
||||||
this.onLifecycleIntent?.(intent);
|
this.onLifecycleIntent?.(intent);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1057,37 +1057,6 @@ export const ValidateBranchRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckoutSwitchBranchRequestSchema = z.object({
|
|
||||||
type: z.literal("checkout_switch_branch_request"),
|
|
||||||
cwd: z.string(),
|
|
||||||
branch: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashSaveRequestSchema = z.object({
|
|
||||||
type: z.literal("stash_save_request"),
|
|
||||||
cwd: z.string(),
|
|
||||||
/** Branch name to tag the stash with for later identification. */
|
|
||||||
branch: z.string().optional(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashPopRequestSchema = z.object({
|
|
||||||
type: z.literal("stash_pop_request"),
|
|
||||||
cwd: z.string(),
|
|
||||||
/** Zero-based index from stash_list_response. */
|
|
||||||
stashIndex: z.number().int().min(0),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashListRequestSchema = z.object({
|
|
||||||
type: z.literal("stash_list_request"),
|
|
||||||
cwd: z.string(),
|
|
||||||
/** If true, only return paseo-created stashes. Default true. */
|
|
||||||
paseoOnly: z.boolean().optional(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const BranchSuggestionsRequestSchema = z.object({
|
export const BranchSuggestionsRequestSchema = z.object({
|
||||||
type: z.literal("branch_suggestions_request"),
|
type: z.literal("branch_suggestions_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1423,10 +1392,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
CheckoutSwitchBranchRequestSchema,
|
|
||||||
StashSaveRequestSchema,
|
|
||||||
StashPopRequestSchema,
|
|
||||||
StashListRequestSchema,
|
|
||||||
ValidateBranchRequestSchema,
|
ValidateBranchRequestSchema,
|
||||||
BranchSuggestionsRequestSchema,
|
BranchSuggestionsRequestSchema,
|
||||||
DirectorySuggestionsRequestSchema,
|
DirectorySuggestionsRequestSchema,
|
||||||
@@ -2227,54 +2192,6 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckoutSwitchBranchResponseSchema = z.object({
|
|
||||||
type: z.literal("checkout_switch_branch_response"),
|
|
||||||
payload: z.object({
|
|
||||||
cwd: z.string(),
|
|
||||||
success: z.boolean(),
|
|
||||||
branch: z.string(),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const StashEntrySchema = z.object({
|
|
||||||
index: z.number().int().min(0),
|
|
||||||
message: z.string(),
|
|
||||||
branch: z.string().nullable(),
|
|
||||||
isPaseo: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashSaveResponseSchema = z.object({
|
|
||||||
type: z.literal("stash_save_response"),
|
|
||||||
payload: z.object({
|
|
||||||
cwd: z.string(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashPopResponseSchema = z.object({
|
|
||||||
type: z.literal("stash_pop_response"),
|
|
||||||
payload: z.object({
|
|
||||||
cwd: z.string(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StashListResponseSchema = z.object({
|
|
||||||
type: z.literal("stash_list_response"),
|
|
||||||
payload: z.object({
|
|
||||||
cwd: z.string(),
|
|
||||||
entries: z.array(StashEntrySchema),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ValidateBranchResponseSchema = z.object({
|
export const ValidateBranchResponseSchema = z.object({
|
||||||
type: z.literal("validate_branch_response"),
|
type: z.literal("validate_branch_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2661,10 +2578,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
CheckoutSwitchBranchResponseSchema,
|
|
||||||
StashSaveResponseSchema,
|
|
||||||
StashPopResponseSchema,
|
|
||||||
StashListResponseSchema,
|
|
||||||
ValidateBranchResponseSchema,
|
ValidateBranchResponseSchema,
|
||||||
BranchSuggestionsResponseSchema,
|
BranchSuggestionsResponseSchema,
|
||||||
DirectorySuggestionsResponseSchema,
|
DirectorySuggestionsResponseSchema,
|
||||||
@@ -2880,15 +2793,6 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
|
|||||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||||
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
|
||||||
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
|
||||||
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
|
||||||
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
|
||||||
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
|
||||||
export type StashPopResponse = z.infer<typeof StashPopResponseSchema>;
|
|
||||||
export type StashListRequest = z.infer<typeof StashListRequestSchema>;
|
|
||||||
export type StashListResponse = z.infer<typeof StashListResponseSchema>;
|
|
||||||
export type StashEntry = z.infer<typeof StashEntrySchema>;
|
|
||||||
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ type CheckoutFileChange = {
|
|||||||
isUntracked?: boolean;
|
isUntracked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BranchSuggestionRefOrigin = "local" | "remote";
|
||||||
|
|
||||||
function normalizeBranchSuggestionName(raw: string): string | null {
|
function normalizeBranchSuggestionName(raw: string): string | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
@@ -175,57 +176,47 @@ function normalizeBranchSuggestionName(raw: string): string | null {
|
|||||||
normalized = normalized.slice("origin/".length);
|
normalized = normalized.slice("origin/".length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized || normalized === "HEAD" || normalized === "origin") {
|
if (!normalized || normalized === "HEAD") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitRef {
|
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
|
||||||
name: string;
|
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
|
||||||
committerDate: number;
|
cwd,
|
||||||
}
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
|
||||||
const { stdout } = await execGit(
|
|
||||||
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
|
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
|
||||||
);
|
|
||||||
return stdout
|
return stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => {
|
.map((line) => line.trim())
|
||||||
const trimmed = line.trim();
|
.filter((line) => line.length > 0);
|
||||||
if (!trimmed) return null;
|
|
||||||
const [name, dateStr] = trimmed.split("\t");
|
|
||||||
if (!name) return null;
|
|
||||||
return { name, committerDate: Number(dateStr) || 0 };
|
|
||||||
})
|
|
||||||
.filter((ref): ref is GitRef => ref !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortBranchSuggestions(
|
function sortBranchSuggestions(
|
||||||
branchNames: string[],
|
branchNames: string[],
|
||||||
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
|
localBranchNames: Set<string>,
|
||||||
query: string,
|
query: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const hasQuery = normalizedQuery.length > 0;
|
const hasQuery = normalizedQuery.length > 0;
|
||||||
return branchNames.sort((a, b) => {
|
return branchNames.sort((a, b) => {
|
||||||
|
const aLower = a.toLowerCase();
|
||||||
|
const bLower = b.toLowerCase();
|
||||||
|
|
||||||
if (hasQuery) {
|
if (hasQuery) {
|
||||||
const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
|
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||||
const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
|
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||||
if (aPrefix !== bPrefix) {
|
if (aPrefix !== bPrefix) {
|
||||||
return aPrefix ? -1 : 1;
|
return aPrefix ? -1 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const aMeta = branchMeta.get(a);
|
const aIsLocal = localBranchNames.has(a);
|
||||||
const bMeta = branchMeta.get(b);
|
const bIsLocal = localBranchNames.has(b);
|
||||||
const aDate = aMeta?.committerDate ?? 0;
|
if (aIsLocal !== bIsLocal) {
|
||||||
const bDate = bMeta?.committerDate ?? 0;
|
return aIsLocal ? -1 : 1;
|
||||||
if (aDate !== bDate) {
|
|
||||||
return bDate - aDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
@@ -247,40 +238,41 @@ export async function listBranchSuggestions(
|
|||||||
listGitRefs(cwd, "refs/remotes/origin"),
|
listGitRefs(cwd, "refs/remotes/origin"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
|
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
|
||||||
|
for (const localRef of localRefs) {
|
||||||
for (const ref of localRefs) {
|
const normalized = normalizeBranchSuggestionName(localRef);
|
||||||
const normalized = normalizeBranchSuggestionName(ref.name);
|
if (!normalized) {
|
||||||
if (!normalized) continue;
|
continue;
|
||||||
const existing = branchMeta.get(normalized);
|
|
||||||
branchMeta.set(normalized, {
|
|
||||||
isLocal: true,
|
|
||||||
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ref of remoteRefs) {
|
|
||||||
const normalized = normalizeBranchSuggestionName(ref.name);
|
|
||||||
if (!normalized) continue;
|
|
||||||
const existing = branchMeta.get(normalized);
|
|
||||||
if (!existing) {
|
|
||||||
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
|
|
||||||
} else {
|
|
||||||
branchMeta.set(normalized, {
|
|
||||||
...existing,
|
|
||||||
committerDate: Math.max(ref.committerDate, existing.committerDate),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||||
|
origins.add("local");
|
||||||
|
merged.set(normalized, origins);
|
||||||
|
}
|
||||||
|
for (const remoteRef of remoteRefs) {
|
||||||
|
const normalized = normalizeBranchSuggestionName(remoteRef);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||||
|
origins.add("remote");
|
||||||
|
merged.set(normalized, origins);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
|
const filteredNames = Array.from(merged.keys()).filter((name) =>
|
||||||
query ? name.toLowerCase().includes(query) : true,
|
query ? name.toLowerCase().includes(query) : true,
|
||||||
);
|
);
|
||||||
if (filteredNames.length === 0) {
|
if (filteredNames.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
|
const localBranchNames = new Set<string>();
|
||||||
|
for (const [name, origins] of merged) {
|
||||||
|
if (origins.has("local")) {
|
||||||
|
localBranchNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
|
||||||
return ordered.slice(0, limit);
|
return ordered.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user