mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d1767a64 | ||
|
|
0ab346e5a2 | ||
|
|
a8c04c26b9 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e |
@@ -26,6 +26,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/app
|
run: npm run typecheck --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Fix Nix hash
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fix-nix-hash:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Fix lockfile and update hash
|
||||||
|
run: ./scripts/update-nix.sh
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
run: |
|
||||||
|
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add package-lock.json nix/package.nix
|
||||||
|
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Nix Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Build Nix package
|
||||||
|
run: nix build .#default -o result
|
||||||
|
|
||||||
|
- name: Verify lockfile is complete
|
||||||
|
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||||
|
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||||
|
run: |
|
||||||
|
node scripts/fix-lockfile.mjs package-lock.json
|
||||||
|
git diff --exit-code package-lock.json || {
|
||||||
|
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||||
|
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||||
|
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check npmDepsHash is up to date
|
||||||
|
run: ./scripts/update-nix.sh --check
|
||||||
@@ -36,6 +36,9 @@ jobs:
|
|||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Build relay dependency
|
- name: Build relay dependency
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,66 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.36 - 2026-03-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Windows drive-letter path handling across the codebase.
|
||||||
|
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added metrics collection and terminal performance tests.
|
||||||
|
|
||||||
|
## 0.1.35 - 2026-03-26
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||||
|
- Codex file deletions now display correctly as removed lines in diffs.
|
||||||
|
- OpenCode questions are now surfaced in the permission UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed queued prompt dispatch after idle transition.
|
||||||
|
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added support for Nix and NixOS installation.
|
||||||
|
|
||||||
|
## 0.1.34 - 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||||
|
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||||
|
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||||
|
- Linked Android download surfaces to the Google Play Store.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||||
|
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||||
|
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||||
|
- Improved Codex activity logs with clearer tool-call summaries.
|
||||||
|
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||||
|
- Improved chat transcript readability.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed `paseo send --no-wait` not taking effect.
|
||||||
|
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||||
|
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||||
|
- Fixed nested Claude Code session detection and provider availability checks.
|
||||||
|
- Fixed agent input focus scoping across panels.
|
||||||
|
- Fixed terminal snapshot ordering when subscribing.
|
||||||
|
- Fixed `chat read --since` to accept message IDs.
|
||||||
|
- Fixed keyboard pane focus syncing with the active panel.
|
||||||
|
- Fixed assistant text selection on web.
|
||||||
|
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||||
|
- Fixed the attach-images button interaction in the message composer.
|
||||||
|
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||||
|
|
||||||
|
## 0.1.33 - 2026-03-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||||
|
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||||
|
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||||
|
- The notification test button now surfaces errors instead of failing silently.
|
||||||
|
|
||||||
## 0.1.32 - 2026-03-23
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
|
|
||||||
## Orchestrator mode
|
## Debugging
|
||||||
|
|
||||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
|
||||||
- Launch agents in the most permissive mode
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
- Set cwd to the repository root
|
|
||||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
|
||||||
|
|||||||
@@ -4,40 +4,88 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">One interface for all your coding agents.</p>
|
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
|
|
||||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||||
|
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
### Desktop app
|
||||||
|
|
||||||
|
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
|
||||||
|
|
||||||
### Headless / server mode
|
### Headless / server mode
|
||||||
|
|
||||||
To run the daemon on a remote or headless machine:
|
Run the daemon on any machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Then connect from the desktop app, mobile app, or CLI.
|
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Everything you can do in the app, you can do from the terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
|
paseo ls # list running agents
|
||||||
|
paseo attach abc123 # stream live output
|
||||||
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
|
# run on a remote daemon
|
||||||
|
paseo --host workstation.local:6767 run "run the full test suite"
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
|
|
||||||
|
## Orchestration skills (Unstable)
|
||||||
|
|
||||||
|
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add getpaseo/paseo
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use them in any agent conversation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||||
|
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||||
|
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||||
|
|
||||||
|
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||||
|
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||||
|
|
||||||
|
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||||
|
# Very opinionated and expects both Codex and Claude to work.
|
||||||
|
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Quick monorepo package map:
|
Quick monorepo package map:
|
||||||
@@ -57,8 +105,12 @@ npm run dev
|
|||||||
# run individual surfaces
|
# run individual surfaces
|
||||||
npm run dev:server
|
npm run dev:server
|
||||||
npm run dev:app
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
npm run dev:website
|
npm run dev:website
|
||||||
|
|
||||||
|
# build the daemon
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
# repo-wide checks
|
# repo-wide checks
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
|||||||
|
|
||||||
## Reporting vulnerabilities
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||||
|
|||||||
+16
-3
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
|
|||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:check # Validate release
|
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||||
|
# ... test builds from the draft release assets ...
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
npm run release:finalize # Publish npm, promote draft to published
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
|
|||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- `release:finalize` publishes npm and promotes the same draft release
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- Use the same semver tag for both; don't cut a second tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
|
|
||||||
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||||
|
|
||||||
|
## Changelog format
|
||||||
|
|
||||||
|
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||||
|
|
||||||
|
```
|
||||||
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] `npm run release:patch` completes successfully
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
|
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772963539,
|
||||||
|
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = paseo;
|
||||||
|
paseo = paseo;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = self.nixosModules.paseo;
|
||||||
|
nixosModules.paseo =
|
||||||
|
{ pkgs, lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./nix/module.nix ];
|
||||||
|
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
pkgs.python3
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.paseo;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.paseo = {
|
||||||
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "paseo" { };
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "User account under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "Group under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default =
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/${cfg.user}/.paseo";
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/''${cfg.user}/.paseo"
|
||||||
|
'';
|
||||||
|
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 6767;
|
||||||
|
description = "Port for the Paseo daemon to listen on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
listenAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address for the Paseo daemon to bind to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedHosts = lib.mkOption {
|
||||||
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
|
default = [ ];
|
||||||
|
example = [ ".example.com" "myhost.local" ];
|
||||||
|
description = ''
|
||||||
|
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||||
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
|
Use a leading dot to match a domain and all its subdomains
|
||||||
|
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||||
|
|
||||||
|
Set to `true` to allow any host (not recommended).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
relay = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
inheritUserEnvironment = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.user != "paseo";
|
||||||
|
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||||
|
description = ''
|
||||||
|
Whether to include the user's profile PATH in the service environment.
|
||||||
|
|
||||||
|
When Paseo runs as a real user (not the default system user), AI agents
|
||||||
|
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||||
|
NixOS profile and system paths so agents can use them without manually
|
||||||
|
setting PATH.
|
||||||
|
|
||||||
|
Enabled by default when `user` is set to a non-default value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = "Extra environment variables for the Paseo daemon.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.paseo = {
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PASEO_HOME = cfg.dataDir;
|
||||||
|
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||||
|
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||||
|
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||||
|
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||||
|
# includes /run/current-system/sw/bin which is a superset of those.
|
||||||
|
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||||
|
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||||
|
"/run/current-system/sw/bin"
|
||||||
|
"/run/wrappers/bin"
|
||||||
|
"/nix/var/nix/profiles/default/bin"
|
||||||
|
]);
|
||||||
|
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||||
|
PASEO_ALLOWED_HOSTS = "true";
|
||||||
|
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||||
|
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
|
||||||
|
ExecStart =
|
||||||
|
"${cfg.package}/bin/paseo-server"
|
||||||
|
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
TimeoutStopSec = 15;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenv,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs_22,
|
||||||
|
python3,
|
||||||
|
makeWrapper,
|
||||||
|
# node-pty needs libuv headers on Linux
|
||||||
|
libuv,
|
||||||
|
}:
|
||||||
|
|
||||||
|
buildNpmPackage rec {
|
||||||
|
pname = "paseo";
|
||||||
|
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = ./..;
|
||||||
|
filter = path: type:
|
||||||
|
let
|
||||||
|
baseName = builtins.baseNameOf path;
|
||||||
|
relPath = lib.removePrefix (toString ./..) path;
|
||||||
|
in
|
||||||
|
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||||
|
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||||
|
# Exclude test fixtures and debug files
|
||||||
|
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||||
|
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||||
|
&& baseName != "node_modules"
|
||||||
|
&& baseName != ".git"
|
||||||
|
&& baseName != ".paseo"
|
||||||
|
&& baseName != ".DS_Store";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
|
||||||
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
|
npmDepsHash = "sha256-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
|
||||||
|
|
||||||
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
# We manually rebuild only node-pty in buildPhase.
|
||||||
|
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
python3 # for node-gyp (node-pty compilation)
|
||||||
|
makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
libuv
|
||||||
|
];
|
||||||
|
|
||||||
|
# Don't use the default npm build hook — we need a custom build sequence
|
||||||
|
dontNpmBuild = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
# Rebuild only node-pty (native addon for terminal emulation).
|
||||||
|
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||||
|
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||||
|
# degrade when unavailable.
|
||||||
|
npm rebuild node-pty
|
||||||
|
|
||||||
|
# Build all daemon packages in dependency order (defined in package.json)
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out/lib/paseo
|
||||||
|
|
||||||
|
# Copy root package metadata
|
||||||
|
cp package.json $out/lib/paseo/
|
||||||
|
|
||||||
|
# Copy node_modules (preserving workspace symlinks)
|
||||||
|
cp -a node_modules $out/lib/paseo/
|
||||||
|
|
||||||
|
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||||
|
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||||
|
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||||
|
name=$(basename "$link")
|
||||||
|
if [ -d "packages/$name/dist" ]; then
|
||||||
|
mkdir -p "$out/lib/paseo/packages/$name"
|
||||||
|
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||||
|
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||||
|
if [ -d "packages/$name/node_modules" ]; then
|
||||||
|
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy CLI bin entry
|
||||||
|
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||||
|
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||||
|
|
||||||
|
# Copy extra server files referenced at runtime
|
||||||
|
for f in agent-prompt.md .env.example; do
|
||||||
|
if [ -f packages/server/$f ]; then
|
||||||
|
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy server scripts (daemon-runner, supervisor) needed by CLI
|
||||||
|
if [ -d packages/server/dist/scripts ]; then
|
||||||
|
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||||
|
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create wrapper for the server entry point (for systemd / direct use)
|
||||||
|
mkdir -p $out/bin
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||||
|
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||||
|
--set NODE_ENV production
|
||||||
|
|
||||||
|
# Create wrapper for the CLI
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||||
|
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||||
|
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||||
|
homepage = "https://github.com/getpaseo/paseo";
|
||||||
|
license = lib.licenses.agpl3Plus;
|
||||||
|
mainProgram = "paseo";
|
||||||
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+80
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -16718,6 +16718,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-log": {
|
||||||
|
"version": "5.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
|
||||||
|
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-publish": {
|
"node_modules/electron-publish": {
|
||||||
"version": "26.8.1",
|
"version": "26.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
||||||
@@ -34842,16 +34851,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.32",
|
"@getpaseo/expo-two-way-audio": "0.1.37",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -34937,6 +34946,8 @@
|
|||||||
},
|
},
|
||||||
"packages/app/node_modules/expo-clipboard": {
|
"packages/app/node_modules/expo-clipboard": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
@@ -34956,6 +34967,8 @@
|
|||||||
},
|
},
|
||||||
"packages/app/node_modules/zod": {
|
"packages/app/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -34963,11 +34976,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.32",
|
"@getpaseo/relay": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -34987,6 +35000,8 @@
|
|||||||
},
|
},
|
||||||
"packages/cli/node_modules/chalk": {
|
"packages/cli/node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
|
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||||
@@ -34997,6 +35012,8 @@
|
|||||||
},
|
},
|
||||||
"packages/cli/node_modules/commander": {
|
"packages/cli/node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -35004,10 +35021,12 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.32",
|
"@getpaseo/cli": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
@@ -35040,7 +35059,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35241,7 +35260,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35267,7 +35286,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35283,13 +35302,13 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.37",
|
||||||
"@getpaseo/relay": "0.1.32",
|
"@getpaseo/relay": "0.1.37",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@sctg/sentencepiece-js": "^1.1.0",
|
"@sctg/sentencepiece-js": "^1.1.0",
|
||||||
@@ -35334,6 +35353,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
|
||||||
|
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.6",
|
"ajv": "^6.12.6",
|
||||||
@@ -35355,6 +35376,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -35369,6 +35392,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
@@ -35415,6 +35440,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/accepts": {
|
"packages/server/node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
@@ -35426,6 +35453,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/ajv": {
|
"packages/server/node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -35440,6 +35469,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/ansi-regex": {
|
"packages/server/node_modules/ansi-regex": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -35450,6 +35481,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/body-parser": {
|
"packages/server/node_modules/body-parser": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
@@ -35468,6 +35501,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/content-disposition": {
|
"packages/server/node_modules/content-disposition": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "5.2.1"
|
"safe-buffer": "5.2.1"
|
||||||
@@ -35478,6 +35513,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/cookie-signature": {
|
"packages/server/node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
@@ -35485,6 +35522,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/finalhandler": {
|
"packages/server/node_modules/finalhandler": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
@@ -35500,6 +35539,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/fresh": {
|
"packages/server/node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -35507,6 +35548,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/media-typer": {
|
"packages/server/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -35514,6 +35557,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/merge-descriptors": {
|
"packages/server/node_modules/merge-descriptors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -35524,6 +35569,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/mime-types": {
|
"packages/server/node_modules/mime-types": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
@@ -35534,6 +35581,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/negotiator": {
|
"packages/server/node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -35572,6 +35621,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/send": {
|
"packages/server/node_modules/send": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
@@ -35592,6 +35643,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/serve-static": {
|
"packages/server/node_modules/serve-static": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -35605,6 +35658,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/strip-ansi": {
|
"packages/server/node_modules/strip-ansi": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.0.1"
|
"ansi-regex": "^6.0.1"
|
||||||
@@ -35618,6 +35673,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/type-is": {
|
"packages/server/node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -35630,6 +35687,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/zod": {
|
"packages/server/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -35637,7 +35696,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
@@ -35663,6 +35722,8 @@
|
|||||||
},
|
},
|
||||||
"packages/website/node_modules/@types/node": {
|
"packages/website/node_modules/@types/node": {
|
||||||
"version": "22.19.6",
|
"version": "22.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||||
|
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+19
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -44,18 +44,18 @@
|
|||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.8",
|
"@biomejs/biome": "^2.4.8",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -76,7 +77,14 @@
|
|||||||
"development",
|
"development",
|
||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
"author": "moboudra",
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
|
|||||||
@@ -448,12 +448,15 @@ export default async function globalSetup() {
|
|||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_DICTATION_ENABLED: "1",
|
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_VOICE_MODE_ENABLED: "1",
|
...(openAiUsable
|
||||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
? {
|
||||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
NODE_ENV: "development",
|
NODE_ENV: "development",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
|
export type TerminalPerfDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createTerminal(
|
||||||
|
cwd: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<{
|
||||||
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
sendTerminalInput(
|
||||||
|
terminalId: string,
|
||||||
|
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||||
|
): void;
|
||||||
|
onTerminalStreamEvent(
|
||||||
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
|
): () => void;
|
||||||
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => TerminalPerfDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||||
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
if (line) {
|
||||||
|
lines.push(line.translateToString(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalContent(
|
||||||
|
page: Page,
|
||||||
|
predicate: (text: string) => boolean,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const text = await getTerminalBufferText(page);
|
||||||
|
if (predicate(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTerminal(
|
||||||
|
page: Page,
|
||||||
|
input: { cwd: string; terminalId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
// Boot the app at the workspace route directly.
|
||||||
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
|
// so the daemon registry is already configured when the app starts.
|
||||||
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||||
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
|
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// The workspace should now query listTerminals and discover our terminal.
|
||||||
|
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||||
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!surfaceVisible) {
|
||||||
|
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||||
|
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (tabExists) {
|
||||||
|
await terminalTab.click();
|
||||||
|
} else {
|
||||||
|
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||||
|
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||||
|
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
|
await newTerminalBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal surface to be visible
|
||||||
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => {
|
||||||
|
// overlay may never appear if attachment is instant
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalSurface.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||||
|
|
||||||
|
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatencySample = {
|
||||||
|
char: string;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures keystroke echo round-trip latency.
|
||||||
|
*
|
||||||
|
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||||
|
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||||
|
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||||
|
*/
|
||||||
|
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
throw new Error("__paseoTerminal not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ((window as any).__perfKeystroke = {
|
||||||
|
promise: null as Promise<number> | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.promise = new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
reject(new Error("keystroke echo timeout (5s)"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
function onKeyDown() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
const start = performance.now();
|
||||||
|
const disposable = term.onWriteParsed(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
disposable.dispose();
|
||||||
|
resolve(performance.now() - start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(char);
|
||||||
|
|
||||||
|
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
|
measureKeystrokeLatency,
|
||||||
|
computePercentile,
|
||||||
|
round2,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
type LatencySample,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const LINE_COUNT = 50_000;
|
||||||
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
|
||||||
|
test.describe("Terminal wire performance", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
if (tempRepo) {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - startMs;
|
||||||
|
|
||||||
|
// seq 1 N outputs each number on its own line
|
||||||
|
const estimatedBytes = Array.from(
|
||||||
|
{ length: LINE_COUNT },
|
||||||
|
(_, i) => String(i + 1).length + 1,
|
||||||
|
).reduce((a, b) => a + b, 0);
|
||||||
|
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
lineCount: LINE_COUNT,
|
||||||
|
estimatedBytes,
|
||||||
|
elapsedMs,
|
||||||
|
throughputMBps: round2(throughputMBps),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("throughput-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||||
|
THROUGHPUT_BUDGET_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Ensure clean prompt state
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const samples: LatencySample[] = [];
|
||||||
|
const chars = "abcdefghijklmnopqrst";
|
||||||
|
|
||||||
|
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
|
||||||
|
const char = chars[i % chars.length];
|
||||||
|
const latencyMs = await measureKeystrokeLatency(page, char);
|
||||||
|
samples.push({ char, latencyMs });
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up typed characters
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
|
||||||
|
const latencies = samples.map((s) => s.latencyMs);
|
||||||
|
const p50 = computePercentile(latencies, 50);
|
||||||
|
const p95 = computePercentile(latencies, 95);
|
||||||
|
const max = Math.max(...latencies);
|
||||||
|
const min = Math.min(...latencies);
|
||||||
|
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
sampleCount: KEYSTROKE_SAMPLE_COUNT,
|
||||||
|
p50Ms: round2(p50),
|
||||||
|
p95Ms: round2(p95),
|
||||||
|
maxMs: round2(max),
|
||||||
|
minMs: round2(min),
|
||||||
|
avgMs: round2(avg),
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
char: s.char,
|
||||||
|
latencyMs: round2(s.latencyMs),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("latency-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
p95,
|
||||||
|
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||||
|
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.32",
|
"@getpaseo/expo-two-way-audio": "0.1.37",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
export const WELCOME_ROUTE = "/welcome";
|
|
||||||
|
|
||||||
export function shouldWaitOnStartupRace(input: {
|
|
||||||
onlineServerId: string | null;
|
|
||||||
hasTimedOut: boolean;
|
|
||||||
isDesktopStartupRace: boolean;
|
|
||||||
daemonCount: number;
|
|
||||||
pathname: string;
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.pathname === WELCOME_ROUTE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.hasTimedOut) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRedirectToWelcome(input: {
|
|
||||||
onlineServerId: string | null;
|
|
||||||
hasTimedOut: boolean;
|
|
||||||
pathname: string;
|
|
||||||
isDesktopStartupRace: boolean;
|
|
||||||
daemonCount: number;
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId || !input.hasTimedOut) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.pathname !== "/" && input.pathname !== "") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,9 @@ import {
|
|||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
void store
|
const init = async () => {
|
||||||
.loadFromStorage()
|
const settings = await loadSettingsFromStorage();
|
||||||
|
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||||
|
await store.loadFromStorage();
|
||||||
|
if (isDesktopManaged) {
|
||||||
|
await store.bootstrap({ manageBuiltInDaemon: true });
|
||||||
|
} else {
|
||||||
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void init()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (cancelled) {
|
if (!cancelled) {
|
||||||
return;
|
setReady(true);
|
||||||
}
|
}
|
||||||
setReady(true);
|
|
||||||
void store.bootstrap();
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[HostRuntime] Failed to initialize store", error);
|
console.error("[HostRuntime] Failed to initialize store", error);
|
||||||
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
}, [isLoading, settings.theme]);
|
}, [isLoading, settings.theme]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingView />;
|
const isDesktopManaged =
|
||||||
|
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||||
|
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,113 +1,18 @@
|
|||||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
|
||||||
import {
|
|
||||||
shouldRedirectToWelcome,
|
|
||||||
shouldWaitOnStartupRace,
|
|
||||||
WELCOME_ROUTE,
|
|
||||||
} from "@/app-support/index-startup";
|
|
||||||
|
|
||||||
const STARTUP_TIMEOUT_MS = 30_000;
|
const WELCOME_ROUTE = "/welcome";
|
||||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
|
||||||
const runtime = getHostRuntimeStore();
|
|
||||||
return useSyncExternalStore(
|
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
|
||||||
() => {
|
|
||||||
let firstOnlineServerId: string | null = null;
|
|
||||||
let firstOnlineAt: string | null = null;
|
|
||||||
for (const serverId of serverIds) {
|
|
||||||
const snapshot = runtime.getSnapshot(serverId);
|
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
|
||||||
firstOnlineAt = lastOnlineAt;
|
|
||||||
firstOnlineServerId = serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstOnlineServerId;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
let firstOnlineServerId: string | null = null;
|
|
||||||
let firstOnlineAt: string | null = null;
|
|
||||||
for (const serverId of serverIds) {
|
|
||||||
const snapshot = runtime.getSnapshot(serverId);
|
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
|
||||||
firstOnlineAt = lastOnlineAt;
|
|
||||||
firstOnlineServerId = serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstOnlineServerId;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
|
||||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setHasTimedOut(true);
|
|
||||||
}, STARTUP_TIMEOUT_MS);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onlineServerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pathname !== "/" && pathname !== "") {
|
if (pathname !== "/" && pathname !== "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
|
||||||
}, [onlineServerId, pathname, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!shouldRedirectToWelcome({
|
|
||||||
onlineServerId,
|
|
||||||
hasTimedOut,
|
|
||||||
pathname,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(WELCOME_ROUTE as any);
|
router.replace(WELCOME_ROUTE as any);
|
||||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
}, [pathname, router]);
|
||||||
|
|
||||||
if (
|
|
||||||
shouldWaitOnStartupRace({
|
|
||||||
onlineServerId,
|
|
||||||
hasTimedOut,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
pathname,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return <StartupSplashScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onlineServerId) {
|
|
||||||
return <WelcomeScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,23 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|
||||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function LegacySettingsRoute() {
|
export default function LegacySettingsRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
|
||||||
|
|
||||||
const targetServerId = useMemo(() => {
|
const targetServerId = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preferences.serverId) {
|
|
||||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
|
||||||
if (match) {
|
|
||||||
return match.serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0]?.serverId ?? null;
|
return daemons[0]?.serverId ?? null;
|
||||||
}, [daemons, preferences.serverId]);
|
}, [daemons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferencesLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||||
}, [preferencesLoading, router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (preferencesLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return <DraftAgentScreen />;
|
return <DraftAgentScreen />;
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
|
|||||||
function getModeColor(modeId?: string): string {
|
function getModeColor(modeId?: string): string {
|
||||||
if (!modeId) return "#9ca3af"; // gray
|
if (!modeId) return "#9ca3af"; // gray
|
||||||
|
|
||||||
// Color based on common mode types
|
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
|
||||||
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
|
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
|
||||||
if (modeId.includes("code")) return "#22c55e"; // green - writes code
|
return "#3b82f6"; // blue - build/auto
|
||||||
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
|
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
|
||||||
|
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
|
||||||
|
return "#22c55e"; // green - safest
|
||||||
|
|
||||||
return "#9ca3af"; // gray - unknown
|
return "#9ca3af"; // gray - unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,14 +537,10 @@ export function AgentConfigRow({
|
|||||||
}, [modeOptions]);
|
}, [modeOptions]);
|
||||||
|
|
||||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
return models.map((model) => ({
|
||||||
for (const model of models) {
|
id: model.id,
|
||||||
opts.push({
|
label: model.label,
|
||||||
id: model.id,
|
}));
|
||||||
label: model.label,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
||||||
@@ -586,7 +582,7 @@ export function AgentConfigRow({
|
|||||||
title="Select model"
|
title="Select model"
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
options={modelSelectOptions}
|
options={modelSelectOptions}
|
||||||
placeholder="Auto"
|
placeholder={isModelLoading ? "Loading..." : "Select model"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
@@ -777,10 +773,8 @@ export function ModelDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel = selectedModel
|
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||||
: "Automatic";
|
|
||||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
|
||||||
const helperText = error
|
const helperText = error
|
||||||
? undefined
|
? undefined
|
||||||
: isLoading
|
: isLoading
|
||||||
@@ -790,34 +784,20 @@ export function ModelDropdown({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [
|
return models.map((model) => ({
|
||||||
{
|
id: model.id,
|
||||||
id: "",
|
label: model.label,
|
||||||
label: "Automatic (provider default)",
|
description: model.description,
|
||||||
description: "Let the assistant pick the recommended model.",
|
}));
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const model of models) {
|
|
||||||
opts.push({
|
|
||||||
id: model.id,
|
|
||||||
label: model.label,
|
|
||||||
description: model.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === "") {
|
onSelect(id);
|
||||||
onClear();
|
|
||||||
} else {
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onClear, onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +59,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
|||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
isInputActive: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
@@ -91,6 +92,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
|||||||
export function AgentInputArea({
|
export function AgentInputArea({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
isInputActive,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
@@ -112,8 +114,6 @@ export function AgentInputArea({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isScreenFocused = useIsFocused();
|
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
@@ -127,7 +127,14 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
const agentState = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
|
return {
|
||||||
|
status: agent?.status ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const queuedMessagesRaw = useSessionStore((state) =>
|
const queuedMessagesRaw = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||||
@@ -260,7 +267,6 @@ export function AgentInputArea({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
await client.sendAgentMessage(agentId, text, {
|
await client.sendAgentMessage(agentId, text, {
|
||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
@@ -274,41 +280,8 @@ export function AgentInputArea({
|
|||||||
onSubmitMessageRef.current = onSubmitMessage;
|
onSubmitMessageRef.current = onSubmitMessage;
|
||||||
}, [onSubmitMessage]);
|
}, [onSubmitMessage]);
|
||||||
|
|
||||||
const isAgentRunning = agent?.status === "running";
|
const isAgentRunning = agentState.status === "running";
|
||||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
const hasAgent = agentState.status !== null;
|
||||||
|
|
||||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
|
||||||
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
|
||||||
useEffect(() => {
|
|
||||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
|
||||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
|
||||||
if (isProcessing && !isAgentRunning) {
|
|
||||||
prevIsAgentRunningRef.current = false;
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasRunning = prevIsAgentRunningRef.current;
|
|
||||||
let shouldClearProcessing = false;
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
const hasEnteredRunning = !wasRunning && isAgentRunning;
|
|
||||||
const hasFreshRunningUpdateWhileRunning =
|
|
||||||
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
|
|
||||||
const hasStoppedRunning = wasRunning && !isAgentRunning;
|
|
||||||
|
|
||||||
shouldClearProcessing =
|
|
||||||
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIsAgentRunningRef.current = isAgentRunning;
|
|
||||||
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
|
||||||
|
|
||||||
if (shouldClearProcessing) {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const updateQueue = useCallback(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||||
@@ -350,7 +323,7 @@ export function AgentInputArea({
|
|||||||
message,
|
message,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
forceSend,
|
forceSend,
|
||||||
isAgentRunning: agent?.status === "running",
|
isAgentRunning: agentState.status === "running",
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
@@ -424,7 +397,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isScreenFocused) {
|
if (!isInputActive) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +433,7 @@ export function AgentInputArea({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isScreenFocused],
|
[isInputActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -472,9 +445,9 @@ export function AgentInputArea({
|
|||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
enabled: isScreenFocused,
|
enabled: isInputActive,
|
||||||
priority: isMessageInputFocused ? 200 : 100,
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
isActive: () => isScreenFocused,
|
isActive: () => isInputActive,
|
||||||
handle: handleKeyboardAction,
|
handle: handleKeyboardAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -483,7 +456,7 @@ export function AgentInputArea({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleCancelAgent() {
|
function handleCancelAgent() {
|
||||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
if (!isAgentRunning || isCancellingAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isConnected || !client) {
|
if (!isConnected || !client) {
|
||||||
@@ -497,7 +470,7 @@ export function AgentInputArea({
|
|||||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
if (!voice || !isConnected || !agent) {
|
if (!voice || !isConnected || !hasAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (voice.isVoiceSwitching) {
|
if (voice.isVoiceSwitching) {
|
||||||
@@ -514,7 +487,7 @@ export function AgentInputArea({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [agent, agentId, isConnected, serverId, toast, voice]);
|
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -606,7 +579,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<View style={styles.rightControls}>
|
<View style={styles.rightControls}>
|
||||||
{!isVoiceModeForAgent && agent ? (
|
{!isVoiceModeForAgent && hasAgent ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleToggleRealtimeVoice}
|
onPress={handleToggleRealtimeVoice}
|
||||||
@@ -716,7 +689,7 @@ export function AgentInputArea({
|
|||||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isScreenFocused={isScreenFocused}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
|
|||||||
try {
|
try {
|
||||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||||
input.clearDraft("sent");
|
input.clearDraft("sent");
|
||||||
|
input.setIsProcessing(false);
|
||||||
return "submitted";
|
return "submitted";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
input.onSubmitError?.(error);
|
input.onSubmitError?.(error);
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty and default values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
expect(normalizeModelId(" default ")).toBeNull();
|
|
||||||
expect(normalizeModelId(undefined)).toBeNull();
|
expect(normalizeModelId(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns trimmed model ids", () => {
|
it("returns trimmed model ids", () => {
|
||||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||||
|
expect(normalizeModelId(" default ")).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
|
|||||||
expect(selection.selectedThinkingId).toBe("high");
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
expect(selection.displayThinking).toBe("High");
|
expect(selection.displayThinking).toBe("High");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model label instead of Auto", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
provider: "codex",
|
||||||
|
label: "Model A",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: null,
|
||||||
|
configuredModelId: null,
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.displayModel).toBe("Model A");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
provider: "claude",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||||
|
configuredModelId: "default",
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.activeModelId).toBe("default");
|
||||||
|
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||||
|
expect(selection.selectedThinkingId).toBe("low");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
@@ -104,21 +106,18 @@ function getModeIconColor(
|
|||||||
palette: {
|
palette: {
|
||||||
blue: { 500: string };
|
blue: { 500: string };
|
||||||
green: { 500: string };
|
green: { 500: string };
|
||||||
amber: { 500: string };
|
|
||||||
red: { 500: string };
|
red: { 500: string };
|
||||||
purple: { 500: string };
|
purple: { 500: string };
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
switch (colorTier) {
|
switch (colorTier) {
|
||||||
case "default":
|
|
||||||
return palette.blue[500];
|
|
||||||
case "safe":
|
case "safe":
|
||||||
return palette.green[500];
|
return palette.green[500];
|
||||||
case "moderate":
|
case "moderate":
|
||||||
return palette.amber[500];
|
return palette.blue[500];
|
||||||
case "dangerous":
|
case "dangerous":
|
||||||
return palette.red[500];
|
return palette.red[500];
|
||||||
case "readonly":
|
case "planning":
|
||||||
return palette.purple[500];
|
return palette.purple[500];
|
||||||
default:
|
default:
|
||||||
return palette.blue[500];
|
return palette.blue[500];
|
||||||
@@ -166,8 +165,12 @@ function ControlledStatusBar({
|
|||||||
const displayModel =
|
const displayModel =
|
||||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||||
? "Loading models..."
|
? "Loading models..."
|
||||||
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
: findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
const displayThinking = findOptionLabel(
|
||||||
|
thinkingOptions,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||||
|
);
|
||||||
|
|
||||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
@@ -603,8 +606,29 @@ function ControlledStatusBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MODES: AgentMode[] = [];
|
||||||
|
|
||||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
const agent = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
|
return currentAgent
|
||||||
|
? {
|
||||||
|
provider: currentAgent.provider,
|
||||||
|
cwd: currentAgent.cwd,
|
||||||
|
currentModeId: currentAgent.currentModeId,
|
||||||
|
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||||
|
model: currentAgent.model,
|
||||||
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const availableModes = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
|
||||||
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const modelsQuery = useQuery({
|
||||||
@@ -631,23 +655,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
const models = modelsQuery.data ?? null;
|
const models = modelsQuery.data ?? null;
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
agent?.currentModeId ||
|
agent?.currentModeId ||
|
||||||
"default";
|
"default";
|
||||||
|
|
||||||
const modelSelection = resolveAgentModelSelection({
|
const modelSelection = resolveAgentModelSelection({
|
||||||
models,
|
models,
|
||||||
runtimeModelId: agent?.runtimeInfo?.model,
|
runtimeModelId: agent?.runtimeModelId,
|
||||||
configuredModelId: agent?.model,
|
configuredModelId: agent?.model,
|
||||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (agent?.availableModes ?? []).map((mode) => ({
|
return availableModes.map((mode) => ({
|
||||||
id: mode.id,
|
id: mode.id,
|
||||||
label: mode.label,
|
label: mode.label,
|
||||||
}));
|
}));
|
||||||
}, [agent?.availableModes]);
|
}, [availableModes]);
|
||||||
|
|
||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
@@ -668,9 +692,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={agent.provider}
|
provider={agent.provider}
|
||||||
modeOptions={
|
modeOptions={
|
||||||
modeOptions.length > 0
|
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
? modeOptions
|
|
||||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
@@ -777,7 +799,7 @@ export function DraftAgentStatusBar({
|
|||||||
label: definition.label,
|
label: definition.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
const modelOptions: StatusOption[] = [];
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
modelOptions.push({ id: model.id, label: model.label });
|
modelOptions.push({ id: model.id, label: model.label });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
|||||||
|
|
||||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
|
|||||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
const runtimeSelectedModel =
|
||||||
|
models && normalizedRuntimeModelId
|
||||||
|
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
|
||||||
|
: null;
|
||||||
|
const preferredModelId =
|
||||||
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
|
const fallbackModel =
|
||||||
|
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
: null;
|
: fallbackModel;
|
||||||
|
|
||||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
const displayModel =
|
||||||
|
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
|
||||||
|
|
||||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||||
const selectedThinkingId =
|
const resolvedThinkingId =
|
||||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||||
? explicitThinkingOptionId
|
? explicitThinkingOptionId
|
||||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||||
const selectedThinking =
|
const selectedThinking =
|
||||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||||
const displayThinking =
|
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||||
selectedThinking?.label ??
|
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
|
|||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agent: Agent;
|
agent: AgentScreenAgent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
|
|||||||
|
|
||||||
type DrillDownView = { provider: string };
|
type DrillDownView = { provider: string };
|
||||||
|
|
||||||
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
|
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||||
|
}
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
|
|||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? "Loading..." : "Auto";
|
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||||
const model = models.find((m) => m.id === selectedModel);
|
const model = models.find((m) => m.id === selectedModel);
|
||||||
return model?.label ?? "Auto";
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
getIsDesktopMac,
|
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -31,8 +30,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
|||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
const collapsedSidebarTrafficLightInset =
|
const collapsedSidebarInset =
|
||||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
|
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||||
|
? trafficLightPadding
|
||||||
|
: { left: 0, right: 0 };
|
||||||
|
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
|
|
||||||
@@ -42,7 +43,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
{
|
||||||
|
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
|
||||||
|
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ function DesktopSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||||
{trafficLightPadding.top > 0 ? (
|
{trafficLightPadding.side === 'left' ? (
|
||||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export interface MessageInputProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoFocusKey?: string;
|
autoFocusKey?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||||
isScreenFocused?: boolean;
|
isInputActive?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||||
@@ -190,7 +190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoFocusKey,
|
autoFocusKey,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isScreenFocused = true,
|
isInputActive = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
[onChangeText, onSubmit, images, isAgentRunning],
|
[onChangeText, onSubmit, images, isAgentRunning],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback((error: Error) => {
|
const handleDictationError = useCallback(
|
||||||
console.error("[MessageInput] Dictation error:", error);
|
(error: Error) => {
|
||||||
}, []);
|
console.error("[MessageInput] Dictation error:", error);
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||||
serverInfo,
|
serverInfo,
|
||||||
@@ -387,7 +391,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
canStart: canStartDictation,
|
canStart: canStartDictation,
|
||||||
canConfirm: canConfirmDictation,
|
canConfirm: canConfirmDictation,
|
||||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
autoStopWhenHidden: { isVisible: isInputActive },
|
||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -950,18 +954,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View style={styles.leftButtonGroup}>
|
<View style={styles.leftButtonGroup}>
|
||||||
{onPickImages && (
|
{onPickImages && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger asChild>
|
||||||
onPress={onPickImages}
|
<Pressable
|
||||||
disabled={!isConnected || disabled}
|
onPress={onPickImages}
|
||||||
accessibilityLabel="Attach images"
|
disabled={!isConnected || disabled}
|
||||||
accessibilityRole="button"
|
accessibilityLabel="Attach images"
|
||||||
style={({ hovered }) => [
|
accessibilityRole="button"
|
||||||
styles.attachButton,
|
style={({ hovered }) => [
|
||||||
hovered && styles.iconButtonHovered,
|
styles.attachButton,
|
||||||
(!isConnected || disabled) && styles.buttonDisabled,
|
hovered && styles.iconButtonHovered,
|
||||||
]}
|
(!isConnected || disabled) && styles.buttonDisabled,
|
||||||
>
|
]}
|
||||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
>
|
||||||
|
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Attach images</Text>
|
<Text style={styles.tooltipText}>Attach images</Text>
|
||||||
@@ -1203,7 +1209,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
leftButtonGroup: {
|
leftButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
rightButtonGroup: {
|
rightButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<Text
|
<Text
|
||||||
key={node.key}
|
key={node.key}
|
||||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||||
selectable={false}
|
selectable={Platform.OS === "web" ? undefined : false}
|
||||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FolderPlus,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Monitor,
|
Monitor,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
@@ -392,15 +392,15 @@ function NewWorktreeButton({
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) =>
|
{({ hovered, pressed }) =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
<Plus
|
<FolderPlus
|
||||||
size={14}
|
size={15}
|
||||||
color={
|
color={
|
||||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
}
|
}
|
||||||
@@ -411,7 +411,7 @@ function NewWorktreeButton({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.projectActionTooltipRow}>
|
<View style={styles.projectActionTooltipRow}>
|
||||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||||
{showShortcutHint && newWorktreeKeys ? (
|
{showShortcutHint && newWorktreeKeys ? (
|
||||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -886,7 +886,10 @@ function SplitPaneView({
|
|||||||
style={[
|
style={[
|
||||||
styles.paneTabs,
|
styles.paneTabs,
|
||||||
isFocusModeEnabled &&
|
isFocusModeEnabled &&
|
||||||
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
|
trafficLightPadding.side && {
|
||||||
|
paddingLeft: trafficLightPadding.left,
|
||||||
|
paddingRight: trafficLightPadding.right,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
|
|||||||
@@ -150,70 +150,136 @@ export function Autocomplete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedOption = options[selectedIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { maxHeight }]}>
|
<View style={styles.outerWrapper}>
|
||||||
<ScrollView
|
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||||
ref={scrollRef}
|
<View style={styles.detailCard}>
|
||||||
onLayout={handleScrollViewLayout}
|
<Text style={styles.detailLabel}>
|
||||||
onContentSizeChange={pinToBottom}
|
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||||
onScroll={(event) => {
|
</Text>
|
||||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
<Text style={styles.detailDescription}>
|
||||||
}}
|
{removeBoltGlyphs(selectedOption.description)}
|
||||||
scrollEventThrottle={16}
|
</Text>
|
||||||
style={styles.scrollView}
|
{selectedOption.detail ? (
|
||||||
contentContainerStyle={styles.scrollContent}
|
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
||||||
keyboardShouldPersistTaps="always"
|
) : null}
|
||||||
>
|
</View>
|
||||||
{options.map((option, index) => {
|
) : null}
|
||||||
const isSelected = index === selectedIndex;
|
<View style={[styles.container, { maxHeight }]}>
|
||||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
<ScrollView
|
||||||
const optionDetail = removeBoltGlyphs(option.detail);
|
ref={scrollRef}
|
||||||
const optionDescription = removeBoltGlyphs(option.description);
|
onLayout={handleScrollViewLayout}
|
||||||
return (
|
onContentSizeChange={pinToBottom}
|
||||||
<Pressable
|
onScroll={(event) => {
|
||||||
key={option.id}
|
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||||
onLayout={(event) => handleRowLayout(index, event)}
|
}}
|
||||||
onPress={() => onSelect(option)}
|
scrollEventThrottle={16}
|
||||||
style={({ hovered = false, pressed }) => [
|
style={styles.scrollView}
|
||||||
styles.item,
|
contentContainerStyle={styles.scrollContent}
|
||||||
(hovered || pressed || isSelected) && styles.itemActive,
|
keyboardShouldPersistTaps="always"
|
||||||
]}
|
>
|
||||||
>
|
{options.map((option, index) => {
|
||||||
{option.kind === "directory" || option.kind === "file" ? (
|
const isSelected = index === selectedIndex;
|
||||||
<View style={styles.itemLeading}>
|
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||||
{option.kind === "directory" ? (
|
const optionDescription = removeBoltGlyphs(option.description);
|
||||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||||
) : (
|
return (
|
||||||
<File size={14} color={theme.colors.foregroundMuted} />
|
<Pressable
|
||||||
)}
|
key={option.id}
|
||||||
</View>
|
onLayout={(event) => handleRowLayout(index, event)}
|
||||||
) : null}
|
onPress={() => onSelect(option)}
|
||||||
<View style={styles.itemMain}>
|
style={({ hovered = false, pressed }) => [
|
||||||
<View style={styles.itemHeader}>
|
styles.item,
|
||||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
(hovered || pressed || isSelected) && styles.itemActive,
|
||||||
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
|
]}
|
||||||
</View>
|
>
|
||||||
{optionDescription ? (
|
{isFileOrDir ? (
|
||||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
<>
|
||||||
{optionDescription}
|
<View style={styles.itemLeading}>
|
||||||
</Text>
|
{option.kind === "directory" ? (
|
||||||
) : null}
|
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
) : (
|
||||||
</Pressable>
|
<File size={14} color={theme.colors.foregroundMuted} />
|
||||||
);
|
)}
|
||||||
})}
|
</View>
|
||||||
</ScrollView>
|
<View style={styles.itemMain}>
|
||||||
|
<View style={styles.itemHeader}>
|
||||||
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||||
|
{removeBoltGlyphs(option.detail) ? (
|
||||||
|
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{optionDescription ? (
|
||||||
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||||
|
{optionDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.itemMainRow}>
|
||||||
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||||
|
{optionDescription ? (
|
||||||
|
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
||||||
|
{optionDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||||
container: {
|
outerWrapper: {
|
||||||
backgroundColor: theme.colors.surface0,
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailCard: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderAccent,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
detailDescription: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailHint: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
marginRight: theme.spacing[1],
|
marginRight: theme.spacing[1],
|
||||||
},
|
},
|
||||||
itemActive: {
|
itemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemMain: {
|
itemMain: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
itemMainRow: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
itemHeader: {
|
itemHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
itemDescriptionInline: {
|
||||||
|
flex: 1,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
emptyItem: {
|
emptyItem: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHostMutations } from "@/runtime/host-runtime";
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHostMutations,
|
||||||
|
useHostRuntimeSnapshot,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
actionTextPrimary: {
|
actionTextPrimary: {
|
||||||
color: theme.colors.accentForeground,
|
color: theme.colors.accentForeground,
|
||||||
},
|
},
|
||||||
|
hostList: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
marginTop: theme.spacing[6],
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
hostRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
hostLabel: {
|
||||||
|
flex: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatus: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatusError: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
versionLabel: {
|
versionLabel: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||||
|
const runtime = getHostRuntimeStore();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
() => {
|
||||||
|
let firstOnlineServerId: string | null = null;
|
||||||
|
let firstOnlineAt: string | null = null;
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
|
firstOnlineAt = lastOnlineAt;
|
||||||
|
firstOnlineServerId = serverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstOnlineServerId;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
let firstOnlineServerId: string | null = null;
|
||||||
|
let firstOnlineAt: string | null = null;
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
|
firstOnlineAt = lastOnlineAt;
|
||||||
|
firstOnlineServerId = serverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstOnlineServerId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const status = snapshot?.connectionStatus ?? "connecting";
|
||||||
|
const lastError = snapshot?.lastError ?? null;
|
||||||
|
|
||||||
|
let dotColor: string;
|
||||||
|
let statusText: string;
|
||||||
|
let isError = false;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
dotColor = theme.colors.success;
|
||||||
|
statusText = "Online";
|
||||||
|
break;
|
||||||
|
case "connecting":
|
||||||
|
case "idle":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Connecting…";
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Offline";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
dotColor = theme.colors.destructive;
|
||||||
|
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
|
||||||
|
isError = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.hostRow}>
|
||||||
|
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
|
||||||
|
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WelcomeScreenProps {
|
export interface WelcomeScreenProps {
|
||||||
onHostAdded?: (profile: HostProfile) => void;
|
onHostAdded?: (profile: HostProfile) => void;
|
||||||
}
|
}
|
||||||
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||||
|
const hosts = useHosts();
|
||||||
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
const pendingNameHostname = useSessionStore(
|
const pendingNameHostname = useSessionStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anyOnlineServerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingNameHost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||||
|
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||||
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
<Text style={styles.subtitle}>
|
||||||
|
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{showHostList && (
|
||||||
|
<View style={styles.hostList}>
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export const MAX_CONTENT_WIDTH = 820;
|
|||||||
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||||
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||||
|
|
||||||
|
// Windows/Linux window controls (minimize/maximize/close) — top-right
|
||||||
|
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||||
|
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||||
|
|
||||||
// Check if running in desktop app (any OS)
|
// Check if running in desktop app (any OS)
|
||||||
function isDesktopEnvironment(): boolean {
|
function isDesktopEnvironment(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getRightSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||||
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
|
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
return;
|
nextIsOpen: isOpen,
|
||||||
}
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
|||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionAgents) {
|
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||||
previousAgentStatusRef.current.clear();
|
previousAgentStatusRef.current,
|
||||||
return;
|
sessionAgents,
|
||||||
}
|
);
|
||||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
|
||||||
for (const nextAgent of sessionAgents.values()) {
|
|
||||||
nextStatuses.set(nextAgent.id, nextAgent.status);
|
|
||||||
}
|
|
||||||
previousAgentStatusRef.current = nextStatuses;
|
|
||||||
}, [sessionAgents]);
|
}, [sessionAgents]);
|
||||||
|
|
||||||
const hydrateWorkspaces = useCallback(
|
const hydrateWorkspaces = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
|
||||||
|
|
||||||
|
function createAgent(status: Agent["status"]): Agent {
|
||||||
|
return {
|
||||||
|
serverId: "server-1",
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
status,
|
||||||
|
createdAt: new Date(0),
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt: new Date(0),
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
title: "Agent",
|
||||||
|
cwd: "/tmp",
|
||||||
|
model: null,
|
||||||
|
labels: {},
|
||||||
|
projectPlacement: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reconcilePreviousAgentStatuses", () => {
|
||||||
|
it("preserves previously seen status for existing agents", () => {
|
||||||
|
const previous = new Map([["agent-1", "running" as const]]);
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds newly seen agents from the current snapshot", () => {
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "idle"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes agents that are no longer present", () => {
|
||||||
|
const previous = new Map([
|
||||||
|
["agent-1", "running" as const],
|
||||||
|
["agent-2", "idle" as const],
|
||||||
|
]);
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all tracked statuses when the session is unavailable", () => {
|
||||||
|
const previous = new Map([["agent-1", "running" as const]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, undefined);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
|
export function reconcilePreviousAgentStatuses(
|
||||||
|
previousStatuses: Map<string, AgentLifecycleStatus>,
|
||||||
|
sessionAgents: Map<string, Agent> | undefined,
|
||||||
|
): Map<string, AgentLifecycleStatus> {
|
||||||
|
if (!sessionAgents) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatuses = new Map(previousStatuses);
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const agent of sessionAgents.values()) {
|
||||||
|
seenAgentIds.add(agent.id);
|
||||||
|
if (!nextStatuses.has(agent.id)) {
|
||||||
|
nextStatuses.set(agent.id, agent.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentId of nextStatuses.keys()) {
|
||||||
|
if (!seenAgentIds.has(agentId)) {
|
||||||
|
nextStatuses.delete(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStatuses;
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getLeftSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
|
|
||||||
// Initialize based on current state
|
// Initialize based on current state
|
||||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
|
nextIsOpen: isOpen,
|
||||||
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
|
const previousIsOpen = prevIsOpen.current;
|
||||||
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevIsOpen.current = isOpen;
|
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(-windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function DesktopPermissionsSection() {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
isSendingTestNotification,
|
isSendingTestNotification,
|
||||||
|
testNotificationError,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
@@ -58,6 +59,11 @@ export function DesktopPermissionsSection() {
|
|||||||
void sendTestNotification();
|
void sendTestNotification();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{testNotificationError ? (
|
||||||
|
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
|
||||||
|
{testNotificationError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<DesktopPermissionRow
|
<DesktopPermissionRow
|
||||||
title="Microphone"
|
title="Microphone"
|
||||||
showBorder
|
showBorder
|
||||||
@@ -80,4 +86,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface UseDesktopPermissionsReturn {
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
requestingPermission: DesktopPermissionKind | null;
|
requestingPermission: DesktopPermissionKind | null;
|
||||||
isSendingTestNotification: boolean;
|
isSendingTestNotification: boolean;
|
||||||
|
testNotificationError: string | null;
|
||||||
refreshPermissions: () => Promise<void>;
|
refreshPermissions: () => Promise<void>;
|
||||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||||
sendTestNotification: () => Promise<void>;
|
sendTestNotification: () => Promise<void>;
|
||||||
@@ -112,22 +113,25 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
[isDesktop, refreshPermissions],
|
[isDesktop, refreshPermissions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const sendTestNotification = useCallback(async () => {
|
const sendTestNotification = useCallback(async () => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSendingTestNotification(true);
|
setIsSendingTestNotification(true);
|
||||||
|
setTestNotificationError(null);
|
||||||
try {
|
try {
|
||||||
const sent = await sendOsNotification({
|
const sent = await sendOsNotification({
|
||||||
title: "Paseo notification test",
|
title: "Paseo notification test",
|
||||||
body: "If you can see this, desktop notifications work.",
|
body: "If you can see this, desktop notifications work.",
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
console.warn("[Settings] Desktop test notification was not delivered");
|
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Settings] Failed to send desktop test notification", error);
|
setTestNotificationError("Failed to send notification.");
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsSendingTestNotification(false);
|
setIsSendingTestNotification(false);
|
||||||
@@ -149,6 +153,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
isSendingTestNotification,
|
isSendingTestNotification,
|
||||||
|
testNotificationError,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
|
|||||||
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps provider thinking preference when it is valid for the effective model", () => {
|
it("prefers provider defaults on fresh drafts", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "medium",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
|
|||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
{ model: "default" },
|
{ model: "default" },
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
it("normalizes legacy model id 'default' to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
{ model: "default" },
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
{ thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
codexModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves thinking unset when the model exposes options without a provider default", () => {
|
||||||
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
claudeModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "claude",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("resolves provider only from allowed provider map", () => {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
import {
|
||||||
|
useFormPreferences,
|
||||||
|
type FormPreferences,
|
||||||
|
type ProviderPreferences,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
// Explicit overrides from URL params or "New Agent" button
|
||||||
export interface FormInitialValues {
|
export interface FormInitialValues {
|
||||||
@@ -102,7 +106,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
|
|||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -117,6 +121,10 @@ function resolveDefaultModel(
|
|||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
function resolveEffectiveModel(
|
||||||
availableModels: AgentModelDefinition[] | null,
|
availableModels: AgentModelDefinition[] | null,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
@@ -134,9 +142,31 @@ function resolveEffectiveModel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that resolves form state from multiple data sources.
|
* Pure function that resolves form state from multiple data sources.
|
||||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||||
*
|
*
|
||||||
* Only resolves fields that haven't been user-modified.
|
* Only resolves fields that haven't been user-modified.
|
||||||
*/
|
*/
|
||||||
@@ -195,25 +225,22 @@ function resolveFormState(
|
|||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
|
||||||
if (initialModel) {
|
if (initialModel) {
|
||||||
// If models aren't loaded yet, trust the initial value
|
|
||||||
// It will be validated once models load
|
|
||||||
if (!availableModels || isValidModel(initialModel)) {
|
if (!availableModels || isValidModel(initialModel)) {
|
||||||
result.model = initialModel;
|
result.model = initialModel;
|
||||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
|
||||||
result.model = preferredModel;
|
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = defaultModelId;
|
||||||
}
|
}
|
||||||
} else if (preferredModel) {
|
} else if (preferredModel) {
|
||||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
|
||||||
// We'll validate once models load and clear it if it isn't available.
|
|
||||||
if (!availableModels || isValidModel(preferredModel)) {
|
if (!availableModels || isValidModel(preferredModel)) {
|
||||||
result.model = preferredModel;
|
result.model = preferredModel;
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = defaultModelId;
|
||||||
}
|
}
|
||||||
|
} else if (defaultModelId) {
|
||||||
|
result.model = defaultModelId;
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = "";
|
||||||
}
|
}
|
||||||
@@ -224,13 +251,17 @@ function resolveFormState(
|
|||||||
typeof initialValues?.thinkingOptionId === "string"
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!userModified.thinkingOptionId) {
|
if (!userModified.thinkingOptionId) {
|
||||||
|
const effectiveModelId = result.model.trim();
|
||||||
|
const preferredThinking = effectiveModelId
|
||||||
|
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
if (initialThinkingOptionId.length > 0) {
|
if (initialThinkingOptionId.length > 0) {
|
||||||
result.thinkingOptionId = initialThinkingOptionId;
|
result.thinkingOptionId = initialThinkingOptionId;
|
||||||
} else if (preferredThinkingOptionId.length > 0) {
|
} else if (preferredThinking.length > 0) {
|
||||||
result.thinkingOptionId = preferredThinkingOptionId;
|
result.thinkingOptionId = preferredThinking;
|
||||||
} else {
|
} else {
|
||||||
result.thinkingOptionId = "";
|
result.thinkingOptionId = "";
|
||||||
}
|
}
|
||||||
@@ -238,27 +269,17 @@ function resolveFormState(
|
|||||||
|
|
||||||
// Validate thinking option once model metadata is available.
|
// Validate thinking option once model metadata is available.
|
||||||
if (availableModels) {
|
if (availableModels) {
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
availableModels,
|
||||||
if (thinkingOptions.length === 0) {
|
modelId: result.model,
|
||||||
result.thinkingOptionId = "";
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
} else {
|
});
|
||||||
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
|
|
||||||
const defaultThinkingOptionId =
|
|
||||||
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
|
|
||||||
result.thinkingOptionId = defaultThinkingOptionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Resolve serverId (independent)
|
// 5. Resolve serverId (independent)
|
||||||
// Only use stored serverId if the host still exists in the registry
|
|
||||||
if (!userModified.serverId) {
|
if (!userModified.serverId) {
|
||||||
if (initialValues?.serverId !== undefined) {
|
if (initialValues?.serverId !== undefined) {
|
||||||
result.serverId = initialValues.serverId;
|
result.serverId = initialValues.serverId;
|
||||||
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
|
|
||||||
result.serverId = preferences.serverId;
|
|
||||||
}
|
}
|
||||||
// else keep current
|
// else keep current
|
||||||
}
|
}
|
||||||
@@ -267,8 +288,6 @@ function resolveFormState(
|
|||||||
if (!userModified.workingDir) {
|
if (!userModified.workingDir) {
|
||||||
if (initialValues?.workingDir !== undefined) {
|
if (initialValues?.workingDir !== undefined) {
|
||||||
result.workingDir = initialValues.workingDir;
|
result.workingDir = initialValues.workingDir;
|
||||||
} else if (preferences?.workingDir) {
|
|
||||||
result.workingDir = preferences.workingDir;
|
|
||||||
}
|
}
|
||||||
// else keep current (empty string)
|
// else keep current (empty string)
|
||||||
}
|
}
|
||||||
@@ -313,7 +332,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
preferences,
|
preferences,
|
||||||
isLoading: isPreferencesLoading,
|
isLoading: isPreferencesLoading,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -537,107 +555,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible || !isCreateFlow) return;
|
|
||||||
if (isPreferencesLoading) return;
|
|
||||||
if (userModified.serverId) return;
|
|
||||||
const serverId = formState.serverId;
|
|
||||||
if (!serverId) return;
|
|
||||||
if (preferences?.serverId === serverId) return;
|
|
||||||
void updatePreferences({ serverId });
|
|
||||||
}, [
|
|
||||||
isVisible,
|
|
||||||
isCreateFlow,
|
|
||||||
isPreferencesLoading,
|
|
||||||
userModified.serverId,
|
|
||||||
formState.serverId,
|
|
||||||
preferences?.serverId,
|
|
||||||
updatePreferences,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// User setters - mark fields as modified and persist to preferences
|
// User setters - mark fields as modified and persist to preferences
|
||||||
const setSelectedServerIdFromUser = useCallback(
|
const setSelectedServerIdFromUser = useCallback(
|
||||||
(value: string | null) => {
|
(value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||||
void updatePreferences({ serverId: value ?? undefined });
|
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
(provider: AgentProvider) => {
|
(provider: AgentProvider) => {
|
||||||
setFormState((prev) => ({ ...prev, provider }));
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
|
||||||
void updatePreferences({ provider });
|
|
||||||
|
|
||||||
// When provider changes, reset mode and model to provider defaults
|
|
||||||
// (unless user has explicitly set them)
|
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
|
const isValidModel = (m: string) =>
|
||||||
|
providerModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
const nextModelId =
|
||||||
|
preferredModel && (!providerModels || isValidModel(preferredModel))
|
||||||
|
? preferredModel
|
||||||
|
: defaultModelId;
|
||||||
|
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
const nextModeId =
|
||||||
|
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
|
||||||
|
? providerPrefs.mode
|
||||||
|
: providerDef?.defaultModeId ?? "";
|
||||||
|
|
||||||
|
const preferredThinking = nextModelId
|
||||||
|
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
|
||||||
|
: "";
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: preferredThinking,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||||
|
void updatePreferences({ provider });
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: nextModeId,
|
||||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
model: nextModelId,
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(provider: AgentProvider, modelId: string) => {
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
model: modelId,
|
model: nextModelId,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: providerDef?.defaultModeId ?? "",
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
void updateProviderPreferences(provider, { model: modelId });
|
|
||||||
},
|
},
|
||||||
[
|
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||||
preferences?.providerPreferences,
|
|
||||||
providerDefinitionMap,
|
|
||||||
updatePreferences,
|
|
||||||
updateProviderPreferences,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
setFormState((prev) => ({ ...prev, modeId }));
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: userModified.thinkingOptionId
|
||||||
|
? formStateRef.current.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
setUserModified((prev) => ({ ...prev, model: true }));
|
||||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[availableModels, userModified.thinkingOptionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
@@ -648,9 +678,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
(value: string) => {
|
(value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||||
void updatePreferences({ workingDir: value });
|
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSelectedServerId = useCallback((value: string | null) => {
|
const setSelectedServerId = useCallback((value: string | null) => {
|
||||||
@@ -662,39 +691,42 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [providerModelsQuery]);
|
}, [providerModelsQuery]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const providerPreferenceUpdates: {
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
mode: string;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
model: string;
|
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
|
||||||
thinkingOptionId?: string;
|
|
||||||
} = {
|
const nextProviderPrefs: ProviderPreferences = {
|
||||||
mode: formState.modeId,
|
model: modelId || undefined,
|
||||||
model: formState.model,
|
mode: formState.modeId || undefined,
|
||||||
|
thinkingByModel: {
|
||||||
|
...existingProviderPrefs?.thinkingByModel,
|
||||||
|
...(modelId && formState.thinkingOptionId
|
||||||
|
? { [modelId]: formState.thinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (userModified.thinkingOptionId) {
|
|
||||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
workingDir: formState.workingDir,
|
|
||||||
provider: formState.provider,
|
provider: formState.provider,
|
||||||
serverId: formState.serverId ?? undefined,
|
providerPreferences: {
|
||||||
|
...preferences?.providerPreferences,
|
||||||
|
[formState.provider]: nextProviderPrefs,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
|
||||||
}, [
|
}, [
|
||||||
formState.modeId,
|
availableModels,
|
||||||
formState.model,
|
formState.model,
|
||||||
|
formState.modeId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.serverId,
|
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
formState.workingDir,
|
preferences?.providerPreferences,
|
||||||
userModified.thinkingOptionId,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
const modeOptions = agentDefinition?.modes ?? [];
|
const modeOptions = agentDefinition?.modes ?? [];
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||||
const modelError =
|
const modelError =
|
||||||
@@ -711,7 +743,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
setProviderFromUser,
|
setProviderFromUser,
|
||||||
selectedMode: formState.modeId,
|
selectedMode: formState.modeId,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
selectedModel: formState.model,
|
selectedModel: resolvedModelId,
|
||||||
setModelFromUser,
|
setModelFromUser,
|
||||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||||
setThinkingOptionFromUser,
|
setThinkingOptionFromUser,
|
||||||
@@ -737,7 +769,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
formState.serverId,
|
formState.serverId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.modeId,
|
formState.modeId,
|
||||||
formState.model,
|
resolvedModelId,
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
formState.workingDir,
|
formState.workingDir,
|
||||||
setSelectedServerId,
|
setSelectedServerId,
|
||||||
@@ -771,5 +803,7 @@ export type CreateAgentInitialValues = FormInitialValues;
|
|||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
|
||||||
|
export interface AgentScreenAgent {
|
||||||
|
serverId: string;
|
||||||
|
id: string;
|
||||||
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
|
cwd: string;
|
||||||
|
projectPlacement?: {
|
||||||
|
checkout?: {
|
||||||
|
cwd?: string;
|
||||||
|
isGit?: boolean;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentScreenMissingState =
|
export type AgentScreenMissingState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
@@ -8,8 +20,8 @@ export type AgentScreenMissingState =
|
|||||||
| { kind: "error"; message: string };
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
export interface AgentScreenMachineInput {
|
export interface AgentScreenMachineInput {
|
||||||
agent: Agent | null;
|
agent: AgentScreenAgent | null;
|
||||||
placeholderAgent: Agent | null;
|
placeholderAgent: AgentScreenAgent | null;
|
||||||
missingAgentState: AgentScreenMissingState;
|
missingAgentState: AgentScreenMissingState;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
@@ -31,7 +43,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
|||||||
|
|
||||||
export interface AgentScreenMachineMemory {
|
export interface AgentScreenMachineMemory {
|
||||||
hasRenderedReady: boolean;
|
hasRenderedReady: boolean;
|
||||||
lastReadyAgent: Agent | null;
|
lastReadyAgent: AgentScreenAgent | null;
|
||||||
activeToastLatch: AgentScreenToastLatch;
|
activeToastLatch: AgentScreenToastLatch;
|
||||||
hadInitialSyncFailure: boolean;
|
hadInitialSyncFailure: boolean;
|
||||||
}
|
}
|
||||||
@@ -70,7 +82,7 @@ export type AgentScreenViewState =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
tag: "ready";
|
tag: "ready";
|
||||||
agent: Agent;
|
agent: AgentScreenAgent;
|
||||||
source: "authoritative" | "optimistic" | "stale";
|
source: "authoritative" | "optimistic" | "stale";
|
||||||
sync: AgentScreenReadySyncState;
|
sync: AgentScreenReadySyncState;
|
||||||
isArchiving: boolean;
|
isArchiving: boolean;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback } from "react";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||||
@@ -10,13 +9,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
|||||||
const providerPreferencesSchema = z.object({
|
const providerPreferencesSchema = z.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingByModel: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
workingDir: z.string().optional(),
|
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
|
||||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,12 +32,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
|||||||
export interface UseFormPreferencesReturn {
|
export interface UseFormPreferencesReturn {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
|
|
||||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||||
updateProviderPreferences: (
|
|
||||||
provider: AgentProvider,
|
|
||||||
updates: Partial<ProviderPreferences>,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
@@ -54,13 +46,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
|
|
||||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||||
|
|
||||||
const getProviderPreferences = useCallback(
|
|
||||||
(provider: AgentProvider): ProviderPreferences | undefined => {
|
|
||||||
return preferences.providerPreferences?.[provider];
|
|
||||||
},
|
|
||||||
[preferences.providerPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePreferences = useCallback(
|
const updatePreferences = useCallback(
|
||||||
async (updates: Partial<FormPreferences>) => {
|
async (updates: Partial<FormPreferences>) => {
|
||||||
const prev =
|
const prev =
|
||||||
@@ -73,32 +58,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProviderPreferences = useCallback(
|
|
||||||
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
|
|
||||||
const prev =
|
|
||||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
|
||||||
DEFAULT_FORM_PREFERENCES;
|
|
||||||
const next: FormPreferences = {
|
|
||||||
...prev,
|
|
||||||
providerPreferences: {
|
|
||||||
...prev.providerPreferences,
|
|
||||||
[provider]: {
|
|
||||||
...prev.providerPreferences?.[provider],
|
|
||||||
...updates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
|
||||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preferences,
|
preferences,
|
||||||
isLoading: isPending,
|
isLoading: isPending,
|
||||||
getProviderPreferences,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
|
|||||||
expect(handle).toHaveBeenCalledTimes(1);
|
expect(handle).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches to the active mounted tab when a newer hidden tab is inactive", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const action: KeyboardActionDefinition = {
|
||||||
|
id: "message-input.dictation-toggle",
|
||||||
|
scope: "message-input",
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatcher.registerHandler({
|
||||||
|
handlerId: "visible-tab",
|
||||||
|
actions: [action.id],
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: () => {
|
||||||
|
calls.push("visible-tab");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatcher.registerHandler({
|
||||||
|
handlerId: "hidden-tab",
|
||||||
|
actions: [action.id],
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => false,
|
||||||
|
handle: () => {
|
||||||
|
calls.push("hidden-tab");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handled = dispatcher.dispatch(action);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(calls).toEqual(["visible-tab"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("tries lower-priority handlers when a higher one does not consume the action", () => {
|
it("tries lower-priority handlers when a higher one does not consume the action", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const action: KeyboardActionDefinition = {
|
const action: KeyboardActionDefinition = {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { Bot } from "lucide-react-native";
|
import { Bot } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||||
@@ -16,6 +18,7 @@ import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
|||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
import {
|
import {
|
||||||
useAgentScreenStateMachine,
|
useAgentScreenStateMachine,
|
||||||
|
type AgentScreenAgent,
|
||||||
type AgentScreenMissingState,
|
type AgentScreenMissingState,
|
||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
@@ -78,11 +81,21 @@ function useAgentPanelDescriptor(
|
|||||||
target: { kind: "agent"; agentId: string },
|
target: { kind: "agent"; agentId: string },
|
||||||
context: { serverId: string },
|
context: { serverId: string },
|
||||||
): PanelDescriptor {
|
): PanelDescriptor {
|
||||||
const agent = useSessionStore(
|
const descriptorState = useSessionStore(
|
||||||
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null,
|
useShallow((state) => {
|
||||||
|
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
|
||||||
|
return {
|
||||||
|
provider: agent?.provider ?? "codex",
|
||||||
|
title: agent?.title ?? null,
|
||||||
|
status: agent?.status ?? null,
|
||||||
|
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||||
|
requiresAttention: agent?.requiresAttention ?? false,
|
||||||
|
attentionReason: agent?.attentionReason ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const provider = agent?.provider ?? "codex";
|
const provider = descriptorState.provider;
|
||||||
const label = resolveWorkspaceAgentTabLabel(agent?.title);
|
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -90,12 +103,12 @@ function useAgentPanelDescriptor(
|
|||||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||||
titleState: label ? "ready" : "loading",
|
titleState: label ? "ready" : "loading",
|
||||||
icon,
|
icon,
|
||||||
statusBucket: agent
|
statusBucket: descriptorState.status
|
||||||
? deriveSidebarStateBucket({
|
? deriveSidebarStateBucket({
|
||||||
status: agent.status,
|
status: descriptorState.status,
|
||||||
pendingPermissionCount: agent.pendingPermissions.length,
|
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
||||||
requiresAttention: agent.requiresAttention,
|
requiresAttention: descriptorState.requiresAttention,
|
||||||
attentionReason: agent.attentionReason,
|
attentionReason: descriptorState.attentionReason,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
@@ -243,8 +256,25 @@ function AgentPanelBody({
|
|||||||
addImagesRef.current = addImages;
|
addImagesRef.current = addImages;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const agent = useSessionStore((state) =>
|
const agentState = useSessionStore(
|
||||||
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined,
|
useShallow((state) => {
|
||||||
|
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
|
||||||
|
return {
|
||||||
|
serverId: agent?.serverId ?? null,
|
||||||
|
id: agent?.id ?? null,
|
||||||
|
status: agent?.status ?? null,
|
||||||
|
cwd: agent?.cwd ?? null,
|
||||||
|
archivedAt: agent?.archivedAt ?? null,
|
||||||
|
requiresAttention: agent?.requiresAttention ?? false,
|
||||||
|
attentionReason: agent?.attentionReason ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) =>
|
||||||
|
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||||
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const streamItemsRaw = useSessionStore((state) =>
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||||
@@ -318,52 +348,14 @@ function AgentPanelBody({
|
|||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
isConnected,
|
isConnected,
|
||||||
requiresAttention: agent?.requiresAttention,
|
requiresAttention: agentState.requiresAttention,
|
||||||
attentionReason: agent?.attentionReason,
|
attentionReason: agentState.attentionReason,
|
||||||
isScreenFocused: isPaneFocused,
|
isScreenFocused: isPaneFocused,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
||||||
}, [attentionController.clearOnAgentBlur]);
|
}, [attentionController.clearOnAgentBlur]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DEBUG: track which selector values change between renders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const debugPrevRef = useRef<Record<string, unknown>>({});
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = debugPrevRef.current;
|
|
||||||
const curr: Record<string, unknown> = {
|
|
||||||
agent,
|
|
||||||
"agent?.status": agent?.status,
|
|
||||||
"agent?.cwd": agent?.cwd,
|
|
||||||
"agent?.updatedAt": agent?.updatedAt,
|
|
||||||
"agent?.requiresAttention": agent?.requiresAttention,
|
|
||||||
streamItemsRaw,
|
|
||||||
"streamItems.length": streamItems.length,
|
|
||||||
allPendingPermissions,
|
|
||||||
isInitializingFromMap,
|
|
||||||
historySyncGeneration,
|
|
||||||
hasAppliedAuthoritativeHistory,
|
|
||||||
agentHistorySyncGeneration,
|
|
||||||
hasSession,
|
|
||||||
isPaneFocused,
|
|
||||||
isConnected,
|
|
||||||
};
|
|
||||||
const changed: string[] = [];
|
|
||||||
for (const key of Object.keys(curr)) {
|
|
||||||
if (!Object.is(prev[key], curr[key])) {
|
|
||||||
changed.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed.length > 0 && Object.keys(prev).length > 0) {
|
|
||||||
console.log("[AgentPanelBody] values changed:", changed.join(", "), {
|
|
||||||
changed: Object.fromEntries(changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
debugPrevRef.current = curr;
|
|
||||||
});
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||||
mode: "translate",
|
mode: "translate",
|
||||||
});
|
});
|
||||||
@@ -488,49 +480,34 @@ function AgentPanelBody({
|
|||||||
}, [optimisticStreamItems, streamItems]);
|
}, [optimisticStreamItems, streamItems]);
|
||||||
|
|
||||||
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
|
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
|
||||||
const authoritativeStatus = agent?.status;
|
const authoritativeStatus = agentState.status;
|
||||||
const isAuthoritativeBootstrapping =
|
const isAuthoritativeBootstrapping =
|
||||||
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
||||||
const showPendingCreateSubmitLoading =
|
const showPendingCreateSubmitLoading =
|
||||||
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
|
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
|
||||||
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
|
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
|
||||||
|
|
||||||
const placeholderAgent: Agent | null = useMemo(() => {
|
const agent: AgentScreenAgent | null =
|
||||||
|
agentState.serverId && agentState.id && agentState.status && agentState.cwd
|
||||||
|
? {
|
||||||
|
serverId: agentState.serverId,
|
||||||
|
id: agentState.id,
|
||||||
|
status: agentState.status,
|
||||||
|
cwd: agentState.cwd,
|
||||||
|
projectPlacement,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
|
||||||
if (!shouldUseOptimisticStream || !agentId) {
|
if (!shouldUseOptimisticStream || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
id: agentId,
|
id: agentId,
|
||||||
provider: "claude",
|
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
lastUserMessageAt: now,
|
|
||||||
lastActivityAt: now,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: false,
|
|
||||||
supportsDynamicModes: false,
|
|
||||||
supportsMcpServers: false,
|
|
||||||
supportsReasoningStream: false,
|
|
||||||
supportsToolInvocations: false,
|
|
||||||
},
|
|
||||||
currentModeId: null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: null,
|
|
||||||
runtimeInfo: {
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: null,
|
|
||||||
model: null,
|
|
||||||
modeId: null,
|
|
||||||
},
|
|
||||||
title: "Agent",
|
|
||||||
cwd: ".",
|
cwd: ".",
|
||||||
model: null,
|
projectPlacement: null,
|
||||||
labels: {},
|
|
||||||
};
|
};
|
||||||
}, [agentId, serverId, shouldUseOptimisticStream]);
|
}, [agentId, serverId, shouldUseOptimisticStream]);
|
||||||
|
|
||||||
@@ -642,7 +619,7 @@ function AgentPanelBody({
|
|||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agent || shouldUseOptimisticStream) {
|
if (agentState.id || shouldUseOptimisticStream) {
|
||||||
if (missingAgentState.kind !== "idle") {
|
if (missingAgentState.kind !== "idle") {
|
||||||
setMissingAgentState({ kind: "idle" });
|
setMissingAgentState({ kind: "idle" });
|
||||||
}
|
}
|
||||||
@@ -717,7 +694,7 @@ function AgentPanelBody({
|
|||||||
setMissingAgentState({ kind: "error", message });
|
setMissingAgentState({ kind: "error", message });
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
agent,
|
agentState.id,
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
ensureAgentIsInitialized,
|
ensureAgentIsInitialized,
|
||||||
@@ -803,10 +780,11 @@ function AgentPanelBody({
|
|||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
|
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
isInputActive={isPaneFocused}
|
||||||
value={agentInputDraft.text}
|
value={agentInputDraft.text}
|
||||||
onChangeText={agentInputDraft.setText}
|
onChangeText={agentInputDraft.setText}
|
||||||
images={agentInputDraft.images}
|
images={agentInputDraft.images}
|
||||||
@@ -831,7 +809,7 @@ function AgentPanelBody({
|
|||||||
streamViewRef.current?.scrollToBottom("message-sent");
|
streamViewRef.current?.scrollToBottom("message-sent");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : agentId && agent?.archivedAt ? (
|
) : agentId && agentState.archivedAt ? (
|
||||||
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
|
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ function useDraftPanelDescriptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DraftPanel() {
|
function DraftPanel() {
|
||||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
|
const {
|
||||||
usePaneContext();
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
tabId,
|
||||||
|
target,
|
||||||
|
isPaneFocused,
|
||||||
|
openFileInWorkspace,
|
||||||
|
retargetCurrentTab,
|
||||||
|
} = usePaneContext();
|
||||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +34,7 @@ function DraftPanel() {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
draftId={target.draftId}
|
draftId={target.draftId}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
onOpenWorkspaceFile={({ filePath }) => {
|
onOpenWorkspaceFile={({ filePath }) => {
|
||||||
openFileInWorkspace(filePath);
|
openFileInWorkspace(filePath);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1069,7 +1069,8 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||||
const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767";
|
const DEFAULT_LOCALHOST_ENDPOINT =
|
||||||
|
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
|
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||||
@@ -1136,7 +1137,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async bootstrap(): Promise<void> {
|
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||||
if (this.bootstrapAttempted) {
|
if (this.bootstrapAttempted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1152,30 +1153,43 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUseDesktopDaemon()) {
|
if (shouldUseDesktopDaemon()) {
|
||||||
await this.bootstrapDesktop();
|
if (options?.manageBuiltInDaemon ?? true) {
|
||||||
|
await this.bootstrapDesktop();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.bootstrapLocalhost();
|
await this.bootstrapLocalhost();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async bootstrapDesktop(): Promise<void> {
|
private async bootstrapDesktop(): Promise<void> {
|
||||||
try {
|
let lastError: unknown = null;
|
||||||
const daemon = await startDesktopDaemon();
|
|
||||||
const connection = connectionFromListen(daemon.listen);
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
if (!connection) {
|
try {
|
||||||
|
const daemon = await startDesktopDaemon();
|
||||||
|
const connection = connectionFromListen(daemon.listen);
|
||||||
|
if (!connection || !daemon.serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.upsertHostConnection({
|
||||||
|
serverId: daemon.serverId,
|
||||||
|
label: daemon.hostname ?? undefined,
|
||||||
|
connection,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`[HostRuntime] Failed to bootstrap desktop daemon (attempt ${attempt}/3)`, error);
|
||||||
|
if (attempt < 3) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, attempt * 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
}
|
||||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
|
||||||
});
|
if (lastError) {
|
||||||
await this.upsertHostConnection({
|
console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError);
|
||||||
serverId,
|
|
||||||
label: hostname ?? daemon.hostname ?? undefined,
|
|
||||||
connection,
|
|
||||||
existingClient: client,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
|
|||||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||||
|
|
||||||
const createAgentClient = sessionClient;
|
const createAgentClient = sessionClient;
|
||||||
|
const effectiveDraftModelId = useMemo(() => {
|
||||||
|
if (selectedModel.trim()) {
|
||||||
|
return selectedModel.trim();
|
||||||
|
}
|
||||||
|
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||||
|
}, [availableModels, selectedModel]);
|
||||||
|
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||||
|
if (selectedThinkingOptionId.trim()) {
|
||||||
|
return selectedThinkingOptionId.trim();
|
||||||
|
}
|
||||||
|
const selectedModelDefinition =
|
||||||
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||||
const cwd = (
|
const cwd = (
|
||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||||
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
|
|||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd,
|
cwd,
|
||||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(selectedThinkingOptionId.trim()
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
effectiveDraftModelId,
|
||||||
|
effectiveDraftThinkingOptionId,
|
||||||
isAttachWorktree,
|
isAttachWorktree,
|
||||||
modeOptions.length,
|
modeOptions.length,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
|
||||||
selectedWorktreePath,
|
selectedWorktreePath,
|
||||||
workingDir,
|
workingDir,
|
||||||
]);
|
]);
|
||||||
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
|
|||||||
if (gitBlockingError) {
|
if (gitBlockingError) {
|
||||||
return gitBlockingError;
|
return gitBlockingError;
|
||||||
}
|
}
|
||||||
|
if (isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
if (!effectiveDraftModelId) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
if (isAttachWorktree && !selectedWorktreePath) {
|
if (isAttachWorktree && !selectedWorktreePath) {
|
||||||
return "Select a worktree to attach";
|
return "Select a worktree to attach";
|
||||||
}
|
}
|
||||||
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
|
|||||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||||
".";
|
".";
|
||||||
const provider = selectedProvider;
|
const provider = selectedProvider;
|
||||||
const model = selectedModel.trim() || null;
|
const model = effectiveDraftModelId || null;
|
||||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
|
|||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||||
|
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||||
const trimmedModel = selectedModel.trim();
|
|
||||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
...(effectiveDraftThinkingOptionId
|
||||||
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveBaseBranch = baseBranch.trim();
|
const effectiveBaseBranch = baseBranch.trim();
|
||||||
@@ -1199,6 +1219,7 @@ function DraftAgentScreenContent({
|
|||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
agentId={draftAgentIdRef.current}
|
agentId={draftAgentIdRef.current}
|
||||||
serverId={selectedServerId ?? ""}
|
serverId={selectedServerId ?? ""}
|
||||||
|
isInputActive={isFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { getIsDesktopMac } from "@/constants/layout";
|
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
@@ -19,8 +18,10 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
|
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
const collapsedSidebarInset =
|
||||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||||
|
? trafficLightPadding
|
||||||
|
: { left: 0, right: 0 };
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,7 +32,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={styles.container} {...dragHandlers}>
|
||||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: collapsedSidebarInset.left, paddingRight: collapsedSidebarInset.right }]}>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
|
|||||||
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
|
|||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(newTheme: AppSettings["theme"]) => {
|
(newTheme: AppSettings["theme"]) => {
|
||||||
void updateSettings({ theme: newTheme });
|
void updateSettings({ theme: newTheme });
|
||||||
if (newTheme === "auto") {
|
|
||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
|
||||||
} else {
|
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
|
||||||
UnistylesRuntime.setTheme(newTheme);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
draftId: string;
|
draftId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||||
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
||||||
};
|
};
|
||||||
@@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
tabId,
|
tabId,
|
||||||
draftId,
|
draftId,
|
||||||
|
isPaneFocused,
|
||||||
onCreated,
|
onCreated,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: WorkspaceDraftAgentTabProps) {
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
@@ -92,6 +95,22 @@ export function WorkspaceDraftAgentTab({
|
|||||||
setWorkingDir(workspaceId);
|
setWorkingDir(workspaceId);
|
||||||
}, [setWorkingDir, workingDir, workspaceId]);
|
}, [setWorkingDir, workingDir, workspaceId]);
|
||||||
|
|
||||||
|
const effectiveDraftModelId = useMemo(() => {
|
||||||
|
if (selectedModel.trim()) {
|
||||||
|
return selectedModel.trim();
|
||||||
|
}
|
||||||
|
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||||
|
}, [availableModels, selectedModel]);
|
||||||
|
|
||||||
|
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||||
|
if (selectedThinkingOptionId.trim()) {
|
||||||
|
return selectedThinkingOptionId.trim();
|
||||||
|
}
|
||||||
|
const selectedModelDefinition =
|
||||||
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@@ -108,6 +127,12 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (providerDefinitions.length === 0) {
|
if (providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
|
if (isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
if (!effectiveDraftModelId) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return "Host is not connected";
|
return "Host is not connected";
|
||||||
}
|
}
|
||||||
@@ -122,8 +147,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
},
|
},
|
||||||
buildDraftAgent: (attempt) => {
|
buildDraftAgent: (attempt) => {
|
||||||
const now = attempt.timestamp;
|
const now = attempt.timestamp;
|
||||||
const model = selectedModel.trim() || null;
|
const model = effectiveDraftModelId || null;
|
||||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
@@ -153,14 +178,14 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||||
const trimmedModel = selectedModel.trim();
|
|
||||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
...(effectiveDraftThinkingOptionId
|
||||||
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
@@ -186,17 +211,17 @@ export function WorkspaceDraftAgentTab({
|
|||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(selectedThinkingOptionId.trim()
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
effectiveDraftModelId,
|
||||||
|
effectiveDraftThinkingOptionId,
|
||||||
modeOptions.length,
|
modeOptions.length,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -243,6 +268,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
isInputActive={isPaneFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
@@ -251,7 +277,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
images={draftInput.images}
|
images={draftInput.images}
|
||||||
onChangeImages={draftInput.setImages}
|
onChangeImages={draftInput.setImages}
|
||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={!isSubmitting}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
statusControls={{
|
statusControls={{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus";
|
||||||
|
|
||||||
|
describe("shouldAutoFocusWorkspaceDraftComposer", () => {
|
||||||
|
it("focuses the draft composer when the pane is focused and idle", () => {
|
||||||
|
expect(
|
||||||
|
shouldAutoFocusWorkspaceDraftComposer({
|
||||||
|
isPaneFocused: true,
|
||||||
|
isSubmitting: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not focus the draft composer when the pane is unfocused", () => {
|
||||||
|
expect(
|
||||||
|
shouldAutoFocusWorkspaceDraftComposer({
|
||||||
|
isPaneFocused: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not focus the draft composer while the draft is submitting", () => {
|
||||||
|
expect(
|
||||||
|
shouldAutoFocusWorkspaceDraftComposer({
|
||||||
|
isPaneFocused: true,
|
||||||
|
isSubmitting: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function shouldAutoFocusWorkspaceDraftComposer(input: {
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return input.isPaneFocused && !input.isSubmitting;
|
||||||
|
}
|
||||||
@@ -37,4 +37,36 @@ describe("createMarkdownStyles", () => {
|
|||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps assistant markdown text selectable on web", () => {
|
||||||
|
const styles = createMarkdownStyles(darkTheme);
|
||||||
|
|
||||||
|
expect(styles.body).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.text).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.heading1).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.link).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.code_inline).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.code_block).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.fence).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.bullet_list_icon).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
expect(styles.ordered_list_icon).toMatchObject({
|
||||||
|
userSelect: "text",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import type { Theme } from "./theme";
|
import type { Theme } from "./theme";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||||
*
|
*
|
||||||
@@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
body: {
|
body: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
@@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
text: {
|
text: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
heading1: {
|
heading1: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize["3xl"],
|
fontSize: theme.fontSize["3xl"],
|
||||||
fontWeight: theme.fontWeight.bold,
|
fontWeight: theme.fontWeight.bold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
heading2: {
|
heading2: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize["2xl"],
|
fontSize: theme.fontSize["2xl"],
|
||||||
fontWeight: theme.fontWeight.bold,
|
fontWeight: theme.fontWeight.bold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
heading3: {
|
heading3: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize.xl,
|
fontSize: theme.fontSize.xl,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
heading4: {
|
heading4: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
heading5: {
|
heading5: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
heading6: {
|
heading6: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
strong: {
|
strong: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
|
||||||
em: {
|
em: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
fontStyle: "italic" as const,
|
fontStyle: "italic" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
s: {
|
s: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
textDecorationLine: "line-through" as const,
|
textDecorationLine: "line-through" as const,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
|
||||||
link: {
|
link: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.accentBright,
|
color: theme.colors.accentBright,
|
||||||
textDecorationLine: "none" as const,
|
textDecorationLine: "none" as const,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
@@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
blocklink: {
|
blocklink: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.accentBright,
|
color: theme.colors.accentBright,
|
||||||
textDecorationLine: "none" as const,
|
textDecorationLine: "none" as const,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
@@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
code_inline: {
|
code_inline: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
@@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
code_block: {
|
code_block: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
@@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fence: {
|
fence: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
@@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
tbody: {},
|
tbody: {},
|
||||||
|
|
||||||
th: {
|
th: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
@@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
td: {
|
td: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
@@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
bullet_list_icon: {
|
bullet_list_icon: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ordered_list_icon: {
|
ordered_list_icon: {
|
||||||
|
...webSelectableTextStyle,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||||
import {
|
import {
|
||||||
getIsDesktopMac,
|
getIsDesktopMac,
|
||||||
|
getIsDesktop,
|
||||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||||
|
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||||
|
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||||
import { isDesktop } from "@/desktop/host";
|
import { isDesktop } from "@/desktop/host";
|
||||||
@@ -120,11 +123,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
|
|||||||
}, [isActive]);
|
}, [isActive]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
if (Platform.OS !== "web" || !getIsDesktop()) return;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let cleanup: (() => void) | undefined;
|
let cleanup: (() => void) | undefined;
|
||||||
@@ -175,12 +178,23 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!getIsDesktopMac() || isFullscreen) {
|
if (!getIsDesktop() || isFullscreen) {
|
||||||
return { left: 0, top: 0 };
|
return { left: 0, right: 0, top: 0, side: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getIsDesktopMac()) {
|
||||||
|
return {
|
||||||
|
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||||
|
right: 0,
|
||||||
|
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||||
|
side: 'left',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
left: 0,
|
||||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||||
|
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||||
|
side: 'right',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
|
|||||||
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats legacy 'default' model ids as unset", () => {
|
it("preserves 'default' as a valid model id", () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
model: "default",
|
model: "default",
|
||||||
runtimeInfo: { model: "default" },
|
runtimeInfo: { model: "default" },
|
||||||
} as Partial<Agent> as Agent;
|
} as Partial<Agent> as Agent;
|
||||||
|
|
||||||
expect(extractAgentModel(agent)).toBeNull();
|
expect(extractAgentModel(agent)).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null {
|
|||||||
const fallbackModel = agent.model;
|
const fallbackModel = agent.model;
|
||||||
if (typeof runtimeModel === "string") {
|
if (typeof runtimeModel === "string") {
|
||||||
const normalized = runtimeModel.trim();
|
const normalized = runtimeModel.trim();
|
||||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
if (normalized.length > 0) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof fallbackModel === "string") {
|
if (typeof fallbackModel === "string") {
|
||||||
const normalized = fallbackModel.trim();
|
const normalized = fallbackModel.trim();
|
||||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
if (normalized.length > 0) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getLeftSidebarAnimationTargets,
|
||||||
|
getRightSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "./sidebar-animation-state";
|
||||||
|
|
||||||
|
describe("sidebar-animation-state", () => {
|
||||||
|
it("requests a sync when the open state changes", () => {
|
||||||
|
expect(
|
||||||
|
shouldSyncSidebarAnimation({
|
||||||
|
previousIsOpen: false,
|
||||||
|
nextIsOpen: true,
|
||||||
|
previousWindowWidth: 390,
|
||||||
|
nextWindowWidth: 390,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests a sync when the viewport width changes", () => {
|
||||||
|
expect(
|
||||||
|
shouldSyncSidebarAnimation({
|
||||||
|
previousIsOpen: false,
|
||||||
|
nextIsOpen: false,
|
||||||
|
previousWindowWidth: 390,
|
||||||
|
nextWindowWidth: 430,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the left sidebar fully off-screen when closed", () => {
|
||||||
|
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||||
|
translateX: -430,
|
||||||
|
backdropOpacity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the right sidebar fully off-screen when closed", () => {
|
||||||
|
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||||
|
translateX: 430,
|
||||||
|
backdropOpacity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
interface SidebarAnimationSyncInput {
|
||||||
|
previousIsOpen: boolean;
|
||||||
|
nextIsOpen: boolean;
|
||||||
|
previousWindowWidth: number;
|
||||||
|
nextWindowWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarAnimationTargetInput {
|
||||||
|
isOpen: boolean;
|
||||||
|
windowWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarAnimationTargets {
|
||||||
|
translateX: number;
|
||||||
|
backdropOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||||
|
return (
|
||||||
|
input.previousIsOpen !== input.nextIsOpen ||
|
||||||
|
input.previousWindowWidth !== input.nextWindowWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeftSidebarAnimationTargets(
|
||||||
|
input: SidebarAnimationTargetInput,
|
||||||
|
): SidebarAnimationTargets {
|
||||||
|
return {
|
||||||
|
translateX: input.isOpen ? 0 : -input.windowWidth,
|
||||||
|
backdropOpacity: input.isOpen ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRightSidebarAnimationTargets(
|
||||||
|
input: SidebarAnimationTargetInput,
|
||||||
|
): SidebarAnimationTargets {
|
||||||
|
return {
|
||||||
|
translateX: input.isOpen ? 0 : input.windowWidth,
|
||||||
|
backdropOpacity: input.isOpen ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { focusWithRetries } from "./web-focus";
|
||||||
|
|
||||||
|
describe("focusWithRetries", () => {
|
||||||
|
let frameQueue: FrameRequestCallback[] = [];
|
||||||
|
let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
frameQueue = [];
|
||||||
|
originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
frameQueue.push(callback);
|
||||||
|
return frameQueue.length;
|
||||||
|
}) as typeof requestAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalRequestAnimationFrame) {
|
||||||
|
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame })
|
||||||
|
.requestAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
function flushAnimationFrames(count: number): void {
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const callbacks = frameQueue;
|
||||||
|
frameQueue = [];
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
callback(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("tries to focus immediately before waiting for animation frames", () => {
|
||||||
|
let focused = false;
|
||||||
|
const focus = vi.fn(() => {
|
||||||
|
focused = true;
|
||||||
|
});
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
|
||||||
|
focusWithRetries({
|
||||||
|
focus,
|
||||||
|
isFocused: () => focused,
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(frameQueue).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retrying on later animation frames until focus succeeds", () => {
|
||||||
|
let focused = false;
|
||||||
|
let attempts = 0;
|
||||||
|
const focus = vi.fn(() => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts >= 3) {
|
||||||
|
focused = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
|
||||||
|
focusWithRetries({
|
||||||
|
focus,
|
||||||
|
isFocused: () => focused,
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
flushAnimationFrames(2);
|
||||||
|
expect(focus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
flushAnimationFrames(2);
|
||||||
|
expect(focus).toHaveBeenCalledTimes(3);
|
||||||
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying after cancellation", () => {
|
||||||
|
const focus = vi.fn();
|
||||||
|
|
||||||
|
const cancel = focusWithRetries({
|
||||||
|
focus,
|
||||||
|
isFocused: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
cancel();
|
||||||
|
flushAnimationFrames(4);
|
||||||
|
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,9 +40,7 @@ export function focusWithRetries({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
tick();
|
||||||
requestAnimationFrame(tick);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.32",
|
"@getpaseo/relay": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { addStopOptions, runStopCommand } from "./commands/agent/stop.js";
|
|||||||
import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
|
import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
|
||||||
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
|
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
|
||||||
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
|
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
|
||||||
|
import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.js";
|
||||||
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
|
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
|
||||||
import { withOutput } from "./output/index.js";
|
import { withOutput } from "./output/index.js";
|
||||||
import { onboardCommand } from "./commands/onboard.js";
|
import { onboardCommand } from "./commands/onboard.js";
|
||||||
@@ -93,6 +94,10 @@ export function createCli(): Command {
|
|||||||
addWaitOptions(program.command("wait")),
|
addWaitOptions(program.command("wait")),
|
||||||
).action(withOutput(runWaitCommand));
|
).action(withOutput(runWaitCommand));
|
||||||
|
|
||||||
|
addJsonAndDaemonHostOptions(
|
||||||
|
addArchiveOptions(program.command("archive")),
|
||||||
|
).action(withOutput(runArchiveCommand));
|
||||||
|
|
||||||
// Top-level local daemon shortcuts
|
// Top-level local daemon shortcuts
|
||||||
program.addCommand(onboardCommand());
|
program.addCommand(onboardCommand());
|
||||||
program.addCommand(daemonStartCommand());
|
program.addCommand(daemonStartCommand());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
@@ -24,6 +24,13 @@ export const archiveSchema: OutputSchema<AgentArchiveResult> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function addArchiveOptions(cmd: Command): Command {
|
||||||
|
return cmd
|
||||||
|
.description('Archive an agent (soft-delete)')
|
||||||
|
.argument("<id>", "Agent ID, prefix, or name")
|
||||||
|
.option("--force", "Force archive running agent (interrupts active run first)");
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentArchiveOptions extends CommandOptions {
|
export interface AgentArchiveOptions extends CommandOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
|
|
||||||
export function addDeleteOptions(cmd: Command): Command {
|
export function addDeleteOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
|
|||||||
if (options.all) {
|
if (options.all) {
|
||||||
agents = agents.filter((a) => !a.archivedAt);
|
agents = agents.filter((a) => !a.archivedAt);
|
||||||
} else if (options.cwd) {
|
} else if (options.cwd) {
|
||||||
const filterCwd = options.cwd;
|
|
||||||
agents = agents.filter((a) => {
|
agents = agents.filter((a) => {
|
||||||
if (a.archivedAt) return false;
|
if (a.archivedAt) return false;
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
});
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
const fetchResult = await client.fetchAgent(id);
|
const fetchResult = await client.fetchAgent(id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { runModeCommand } from "./mode.js";
|
import { runModeCommand } from "./mode.js";
|
||||||
import { runArchiveCommand } from "./archive.js";
|
import { addArchiveOptions, runArchiveCommand } from "./archive.js";
|
||||||
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
|
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
|
||||||
import { addLsOptions, runLsCommand } from "./ls.js";
|
import { addLsOptions, runLsCommand } from "./ls.js";
|
||||||
import { addRunOptions, runRunCommand } from "./run.js";
|
import { addRunOptions, runRunCommand } from "./run.js";
|
||||||
@@ -69,11 +69,7 @@ export function createAgentCommand(): Command {
|
|||||||
).action(withOutput(runModeCommand));
|
).action(withOutput(runModeCommand));
|
||||||
|
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
agent
|
addArchiveOptions(agent.command("archive")),
|
||||||
.command("archive")
|
|
||||||
.description("Archive an agent (soft-delete)")
|
|
||||||
.argument("<id>", "Agent ID, prefix, or name")
|
|
||||||
.option("--force", "Force archive running agent (interrupts active run first)"),
|
|
||||||
).action(withOutput(runArchiveCommand));
|
).action(withOutput(runArchiveCommand));
|
||||||
|
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
|
|||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||||
import { collectMultiple } from "../../utils/command-options.js";
|
import { collectMultiple } from "../../utils/command-options.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
|
|
||||||
export function addLsOptions(cmd: Command): Command {
|
export function addLsOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
@@ -193,11 +194,7 @@ export async function runLsCommand(
|
|||||||
|
|
||||||
// Optional cwd filter.
|
// Optional cwd filter.
|
||||||
if (options.cwd) {
|
if (options.cwd) {
|
||||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||||
agents = agents.filter((a) => {
|
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply label filtering only when explicitly requested.
|
// Apply label filtering only when explicitly requested.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentSendOptions extends CommandOptions {
|
export interface AgentSendOptions extends CommandOptions {
|
||||||
noWait?: boolean;
|
wait?: boolean;
|
||||||
image?: string[];
|
image?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
promptFile?: string;
|
promptFile?: string;
|
||||||
@@ -193,7 +193,7 @@ export async function runSendCommand(
|
|||||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||||
|
|
||||||
// If --no-wait, return immediately
|
// If --no-wait, return immediately
|
||||||
if (options.noWait) {
|
if (options.wait === false) {
|
||||||
await client.close();
|
await client.close();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
SingleResult,
|
SingleResult,
|
||||||
@@ -75,12 +76,9 @@ export async function runStopCommand(
|
|||||||
agents = agents.filter((a) => !a.archivedAt);
|
agents = agents.filter((a) => !a.archivedAt);
|
||||||
} else if (options.cwd) {
|
} else if (options.cwd) {
|
||||||
// Stop agents in directory
|
// Stop agents in directory
|
||||||
const filterCwd = options.cwd;
|
|
||||||
agents = agents.filter((a) => {
|
agents = agents.filter((a) => {
|
||||||
if (a.archivedAt) return false;
|
if (a.archivedAt) return false;
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
});
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
// Stop specific agent
|
// Stop specific agent
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
|||||||
normalized.startsWith("/") ||
|
normalized.startsWith("/") ||
|
||||||
normalized.startsWith("unix://") ||
|
normalized.startsWith("unix://") ||
|
||||||
normalized.startsWith("pipe://") ||
|
normalized.startsWith("pipe://") ||
|
||||||
normalized.startsWith("\\\\.\\pipe\\")
|
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||||
|
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { platform } from "node:os";
|
||||||
|
|
||||||
export interface NodePathFromPidResult {
|
export interface NodePathFromPidResult {
|
||||||
nodePath: string | null;
|
nodePath: string | null;
|
||||||
@@ -12,17 +13,14 @@ function normalizeError(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
|
||||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return {
|
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
|
||||||
nodePath: null,
|
|
||||||
error: `ps failed: ${normalizeError(result.error)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((result.status ?? 1) !== 0) {
|
if ((result.status ?? 1) !== 0) {
|
||||||
@@ -34,12 +32,36 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolved = result.stdout.trim();
|
const resolved = result.stdout.trim();
|
||||||
if (!resolved) {
|
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||||
|
const result = spawnSync(
|
||||||
|
"wmic",
|
||||||
|
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
|
||||||
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((result.status ?? 1) !== 0) {
|
||||||
|
const details = result.stderr?.trim();
|
||||||
return {
|
return {
|
||||||
nodePath: null,
|
nodePath: null,
|
||||||
error: "ps returned an empty command path",
|
error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodePath: resolved };
|
// wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
|
||||||
|
const match = result.stdout.match(/ExecutablePath=(.+)/);
|
||||||
|
const resolved = match?.[1]?.trim();
|
||||||
|
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||||
|
return platform() === "win32"
|
||||||
|
? resolveNodePathFromPidWindows(pid)
|
||||||
|
: resolveNodePathFromPidUnix(pid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from "path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
|
|||||||
|
|
||||||
// Find the worktree by name or branch
|
// Find the worktree by name or branch
|
||||||
const worktree = listResponse.worktrees.find((wt) => {
|
const worktree = listResponse.worktrees.find((wt) => {
|
||||||
const name = wt.worktreePath.split("/").pop();
|
const name = path.basename(wt.worktreePath);
|
||||||
return name === nameArg || wt.branchName === nameArg;
|
return name === nameArg || wt.branchName === nameArg;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "single",
|
type: "single",
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
|
|||||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.isAbsolute(trimmed)) {
|
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||||
return `unix://${trimmed}`;
|
return `unix://${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
|
||||||
|
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (/^\d+$/.test(trimmed)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
return `127.0.0.1:${trimmed}`;
|
return `127.0.0.1:${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Path utilities for cwd filtering in agent commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
|
||||||
|
*
|
||||||
|
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
|
||||||
|
* This is important because agent cwd paths come from the agent's OS (could be Windows)
|
||||||
|
* while the CLI filter path comes from the user (could also be Windows or Unix).
|
||||||
|
*/
|
||||||
|
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
|
||||||
|
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
|
||||||
|
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
|
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
|
|
||||||
|
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
|
||||||
|
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
|
||||||
|
normalizedBase = normalizedBase.toLowerCase();
|
||||||
|
normalizedCandidate = normalizedCandidate.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedCandidate === normalizedBase ||
|
||||||
|
normalizedCandidate.startsWith(normalizedBase + "/")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
|
|||||||
console.log("✓ rejects empty and non-host listen values\n");
|
console.log("✓ rejects empty and non-host listen values\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
|
||||||
|
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||||
|
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
|
||||||
|
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
|
||||||
|
console.log("✓ rejects Windows absolute paths\n");
|
||||||
|
}
|
||||||
|
|
||||||
console.log("=== All local daemon utility tests passed ===");
|
console.log("=== All local daemon utility tests passed ===");
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
|||||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
|
||||||
|
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||||
|
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
||||||
|
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
||||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CWD Filter Path Tests
|
||||||
|
*
|
||||||
|
* Tests that cwd filtering in agent commands works correctly
|
||||||
|
* with both Unix and Windows-style paths.
|
||||||
|
*
|
||||||
|
* Bug: The original code used hardcoded "/" separators for:
|
||||||
|
* 1. Stripping trailing separators (only stripped "/", not "\")
|
||||||
|
* 2. Descendant matching (appended "/" instead of platform separator)
|
||||||
|
*
|
||||||
|
* This causes Windows paths like "C:\Users\dev\project" to fail matching
|
||||||
|
* against "C:\Users\dev\project\sub" because the code appends "/" instead
|
||||||
|
* of "\" for the startsWith check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
|
||||||
|
|
||||||
|
console.log("=== CWD Filter Path Tests ===\n");
|
||||||
|
|
||||||
|
// Test 1: Unix exact match
|
||||||
|
{
|
||||||
|
console.log("Test 1: Unix exact match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
|
||||||
|
true,
|
||||||
|
"exact Unix paths should match",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix exact match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Unix descendant match
|
||||||
|
{
|
||||||
|
console.log("Test 2: Unix descendant match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
|
||||||
|
true,
|
||||||
|
"Unix descendant should match",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix descendant match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Unix non-match (sibling)
|
||||||
|
{
|
||||||
|
console.log("Test 3: Unix non-match (sibling)");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
|
||||||
|
false,
|
||||||
|
"sibling directories should not match",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix non-match (sibling)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Unix prefix overlap (project vs project2)
|
||||||
|
{
|
||||||
|
console.log("Test 4: Unix prefix overlap (project vs project2)");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
|
||||||
|
false,
|
||||||
|
"prefix overlap without separator should not match",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix prefix overlap\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Unix trailing slash on base
|
||||||
|
{
|
||||||
|
console.log("Test 5: Unix trailing slash on base");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
|
||||||
|
true,
|
||||||
|
"trailing slash on base should still match descendants",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix trailing slash on base\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Unix trailing slash on candidate
|
||||||
|
{
|
||||||
|
console.log("Test 6: Unix trailing slash on candidate");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
|
||||||
|
true,
|
||||||
|
"trailing slash on candidate should match as same dir",
|
||||||
|
);
|
||||||
|
console.log("✓ Unix trailing slash on candidate\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: Windows exact match
|
||||||
|
{
|
||||||
|
console.log("Test 7: Windows exact match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
|
||||||
|
true,
|
||||||
|
"exact Windows paths should match",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows exact match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: Windows descendant match
|
||||||
|
{
|
||||||
|
console.log("Test 8: Windows descendant match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
|
||||||
|
true,
|
||||||
|
"Windows descendant should match",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows descendant match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Windows non-match (sibling)
|
||||||
|
{
|
||||||
|
console.log("Test 9: Windows non-match (sibling)");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
|
||||||
|
false,
|
||||||
|
"Windows sibling directories should not match",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows non-match (sibling)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: Windows prefix overlap (project vs project2)
|
||||||
|
{
|
||||||
|
console.log("Test 10: Windows prefix overlap (project vs project2)");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
|
||||||
|
false,
|
||||||
|
"Windows prefix overlap without separator should not match",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows prefix overlap\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: Windows trailing backslash on base
|
||||||
|
{
|
||||||
|
console.log("Test 11: Windows trailing backslash on base");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
|
||||||
|
true,
|
||||||
|
"trailing backslash on base should still match descendants",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows trailing backslash on base\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12: Windows trailing backslash on candidate
|
||||||
|
{
|
||||||
|
console.log("Test 12: Windows trailing backslash on candidate");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
|
||||||
|
true,
|
||||||
|
"trailing backslash on candidate should match as same dir",
|
||||||
|
);
|
||||||
|
console.log("✓ Windows trailing backslash on candidate\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13: Mixed separators (agent might use \ while CLI sends /)
|
||||||
|
{
|
||||||
|
console.log("Test 13: Mixed separators");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
|
||||||
|
true,
|
||||||
|
"mixed separators should still match",
|
||||||
|
);
|
||||||
|
console.log("✓ Mixed separators\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 14: Deep Windows descendant
|
||||||
|
{
|
||||||
|
console.log("Test 14: Deep Windows descendant");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath(
|
||||||
|
"C:\\Users\\dev\\project",
|
||||||
|
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
"deep Windows descendant should match",
|
||||||
|
);
|
||||||
|
console.log("✓ Deep Windows descendant\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 15: Case-insensitive Windows descendant match
|
||||||
|
{
|
||||||
|
console.log("Test 15: Case-insensitive Windows descendant match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
|
||||||
|
true,
|
||||||
|
"Windows paths with different casing should match (case-insensitive)",
|
||||||
|
);
|
||||||
|
console.log("✓ Case-insensitive Windows descendant match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 16: Case-insensitive Windows exact match
|
||||||
|
{
|
||||||
|
console.log("Test 16: Case-insensitive Windows exact match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
|
||||||
|
true,
|
||||||
|
"Windows paths with different casing should match exactly (case-insensitive)",
|
||||||
|
);
|
||||||
|
console.log("✓ Case-insensitive Windows exact match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 17: Parent should not match
|
||||||
|
{
|
||||||
|
console.log("Test 15: Parent should not match");
|
||||||
|
assert.strictEqual(
|
||||||
|
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
|
||||||
|
false,
|
||||||
|
"parent directory should not match (only same-or-descendant)",
|
||||||
|
);
|
||||||
|
console.log("✓ Parent should not match\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== All CWD filter path tests passed ===");
|
||||||
@@ -9,5 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
set "PASEO_DESKTOP_CLI=1"
|
set "ELECTRON_RUN_AS_NODE=1"
|
||||||
"%APP_EXECUTABLE%" %*
|
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|||||||
@@ -6,5 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -6,5 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ publish:
|
|||||||
owner: getpaseo
|
owner: getpaseo
|
||||||
repo: paseo
|
repo: paseo
|
||||||
mac:
|
mac:
|
||||||
|
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
icon: assets/icon.icns
|
icon: assets/icon.icns
|
||||||
hardenedRuntime: true
|
hardenedRuntime: true
|
||||||
@@ -30,11 +31,15 @@ linux:
|
|||||||
category: Development
|
category: Development
|
||||||
icon: assets
|
icon: assets
|
||||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||||
|
maintainer: "Mohamed Boudra <hello@moboudra.com>"
|
||||||
|
vendor: "Paseo"
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: bin/paseo
|
- from: bin/paseo
|
||||||
to: bin/paseo
|
to: bin/paseo
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
- tar.gz
|
- tar.gz
|
||||||
win:
|
win:
|
||||||
icon: assets/icon.ico
|
icon: assets/icon.ico
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
@@ -12,8 +12,9 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.32",
|
"@getpaseo/cli": "0.1.37",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.37",
|
||||||
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
@@ -25,5 +26,15 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unzip-crx-3": "^0.2.0",
|
"unzip-crx-3": "^0.2.0",
|
||||||
"wait-on": "8.0.5"
|
"wait-on": "8.0.5"
|
||||||
}
|
},
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"license": "AGPL-3.0-or-later"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,125 @@ const WRAPPER_SCRIPT = `#!/bin/bash
|
|||||||
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
|
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// electron-builder arch enum → Node.js arch string
|
||||||
|
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||||
|
|
||||||
|
const RIPGREP_PLATFORM_DIR = {
|
||||||
|
darwin: { arm64: "arm64-darwin", x64: "x64-darwin" },
|
||||||
|
linux: { arm64: "arm64-linux", x64: "x64-linux" },
|
||||||
|
win32: { arm64: "arm64-win32", x64: "x64-win32" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function rmSafe(target) {
|
||||||
|
fs.rmSync(target, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneChildrenExcept(parent, keep) {
|
||||||
|
if (!fs.existsSync(parent)) return;
|
||||||
|
for (const entry of fs.readdirSync(parent)) {
|
||||||
|
if (!keep.has(entry)) {
|
||||||
|
rmSafe(path.join(parent, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOnnxRuntime(nodeModules, platform, arch) {
|
||||||
|
const onnxBin = path.join(nodeModules, "onnxruntime-node", "bin", "napi-v6");
|
||||||
|
if (!fs.existsSync(onnxBin)) return;
|
||||||
|
|
||||||
|
const otherPlatforms = ["darwin", "linux", "win32"].filter((p) => p !== platform);
|
||||||
|
for (const p of otherPlatforms) {
|
||||||
|
rmSafe(path.join(onnxBin, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneChildrenExcept(path.join(onnxBin, platform), new Set([arch]));
|
||||||
|
|
||||||
|
if (platform === "linux") {
|
||||||
|
const archDir = path.join(onnxBin, "linux", arch);
|
||||||
|
if (fs.existsSync(archDir)) {
|
||||||
|
for (const name of fs.readdirSync(archDir)) {
|
||||||
|
if (name.includes("cuda") || name.includes("tensorrt")) {
|
||||||
|
fs.rmSync(path.join(archDir, name), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
||||||
|
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
||||||
|
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
||||||
|
if (!keepName) return;
|
||||||
|
|
||||||
|
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||||
|
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneNodePty(nodeModules, platform, arch) {
|
||||||
|
const prebuilds = path.join(nodeModules, "node-pty", "prebuilds");
|
||||||
|
pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`]));
|
||||||
|
|
||||||
|
if (platform !== "win32") {
|
||||||
|
rmSafe(path.join(nodeModules, "node-pty", "third_party"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSharpLibvips(nodeModules, platform, arch) {
|
||||||
|
const prefix = `sharp-libvips-${platform}-${arch}`;
|
||||||
|
const imgDir = path.join(nodeModules, "@img");
|
||||||
|
if (!fs.existsSync(imgDir)) return;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(imgDir)) {
|
||||||
|
if (entry.startsWith("sharp-") && entry !== prefix && !entry.startsWith(`sharp-${platform}-${arch}`)) {
|
||||||
|
rmSafe(path.join(imgDir, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneNativeModules(appOutDir, platform, arch) {
|
||||||
|
const resourcesDir =
|
||||||
|
platform === "darwin"
|
||||||
|
? path.join(appOutDir, `${EXECUTABLE_NAME}.app`, "Contents", "Resources")
|
||||||
|
: path.join(appOutDir, "resources");
|
||||||
|
|
||||||
|
const nodeModules = path.join(resourcesDir, "app.asar.unpacked", "node_modules");
|
||||||
|
if (!fs.existsSync(nodeModules)) return;
|
||||||
|
|
||||||
|
const before = dirSizeSync(nodeModules);
|
||||||
|
|
||||||
|
pruneOnnxRuntime(nodeModules, platform, arch);
|
||||||
|
pruneClaudeAgentSdk(nodeModules, platform, arch);
|
||||||
|
pruneNodePty(nodeModules, platform, arch);
|
||||||
|
pruneSharpLibvips(nodeModules, platform, arch);
|
||||||
|
|
||||||
|
const after = dirSizeSync(nodeModules);
|
||||||
|
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
|
||||||
|
console.log(`Pruned native modules: ${savedMB} MB removed (${fmtMB(before)} → ${fmtMB(after)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirSizeSync(dir) {
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
try {
|
||||||
|
total += fs.statSync(path.join(entry.parentPath || entry.path, entry.name)).size;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMB(bytes) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
exports.default = async function afterPack(context) {
|
exports.default = async function afterPack(context) {
|
||||||
if (context.electronPlatformName !== "linux") return;
|
const platform = context.electronPlatformName;
|
||||||
|
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||||
|
|
||||||
|
pruneNativeModules(context.appOutDir, platform, arch);
|
||||||
|
|
||||||
|
if (platform !== "linux") return;
|
||||||
|
|
||||||
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
|
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
|
||||||
if (fs.existsSync(chromeSandbox)) {
|
if (fs.existsSync(chromeSandbox)) {
|
||||||
|
|||||||
@@ -148,8 +148,6 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
|
|||||||
.filter((value) => value.length > 0),
|
.filter((value) => value.length > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
origins.add("paseo://app");
|
|
||||||
|
|
||||||
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
|
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(devServerUrl);
|
const parsed = new URL(devServerUrl);
|
||||||
@@ -189,7 +187,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
|
|||||||
normalized.startsWith("/") ||
|
normalized.startsWith("/") ||
|
||||||
normalized.startsWith("unix://") ||
|
normalized.startsWith("unix://") ||
|
||||||
normalized.startsWith("pipe://") ||
|
normalized.startsWith("pipe://") ||
|
||||||
normalized.startsWith("\\\\.\\pipe\\")
|
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||||
|
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -544,12 +543,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
return downloadAndInstallUpdate(currentVersion);
|
return downloadAndInstallUpdate(currentVersion);
|
||||||
},
|
},
|
||||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||||
webview_log: (args) => {
|
|
||||||
const level = typeof args?.level === "number" ? args.level : 1;
|
|
||||||
const message = typeof args?.message === "string" ? args.message : "";
|
|
||||||
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
|
|
||||||
console[method]("[webview]", message);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,30 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNodeExecPath(): string {
|
||||||
|
if (app.isPackaged && process.platform === "darwin") {
|
||||||
|
const marker = ".app/Contents/MacOS/";
|
||||||
|
const markerIndex = process.execPath.indexOf(marker);
|
||||||
|
if (markerIndex !== -1) {
|
||||||
|
const bundleRoot = process.execPath.substring(0, markerIndex + ".app".length);
|
||||||
|
const name = path.basename(process.execPath);
|
||||||
|
const helperPath = path.join(
|
||||||
|
bundleRoot,
|
||||||
|
"Contents",
|
||||||
|
"Frameworks",
|
||||||
|
`${name} Helper.app`,
|
||||||
|
"Contents",
|
||||||
|
"MacOS",
|
||||||
|
`${name} Helper`,
|
||||||
|
);
|
||||||
|
if (existsSync(helperPath)) {
|
||||||
|
return helperPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return process.execPath;
|
||||||
|
}
|
||||||
|
|
||||||
export function createNodeEntrypointInvocation(input: {
|
export function createNodeEntrypointInvocation(input: {
|
||||||
entrypoint: NodeEntrypointSpec;
|
entrypoint: NodeEntrypointSpec;
|
||||||
argvMode: NodeEntrypointArgvMode;
|
argvMode: NodeEntrypointArgvMode;
|
||||||
@@ -175,7 +199,7 @@ export function createNodeEntrypointInvocation(input: {
|
|||||||
baseEnv: NodeJS.ProcessEnv;
|
baseEnv: NodeJS.ProcessEnv;
|
||||||
}): NodeEntrypointInvocation {
|
}): NodeEntrypointInvocation {
|
||||||
return createSharedNodeEntrypointInvocation({
|
return createSharedNodeEntrypointInvocation({
|
||||||
execPath: process.execPath,
|
execPath: resolveNodeExecPath(),
|
||||||
isPackaged: app.isPackaged,
|
isPackaged: app.isPackaged,
|
||||||
packagedRunnerPath: app.isPackaged
|
packagedRunnerPath: app.isPackaged
|
||||||
? assertPathExists({
|
? assertPathExists({
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import log from "electron-log/main";
|
||||||
|
log.initialize({ spyRendererConsole: true });
|
||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
@@ -89,8 +92,17 @@ async function createMainWindow(): Promise<void> {
|
|||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
titleBarStyle: isMac ? "hidden" : "default",
|
titleBarStyle: "hidden",
|
||||||
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
|
...(isMac
|
||||||
|
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||||
|
: {
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: "#18181c",
|
||||||
|
symbolColor: "#e4e4e7",
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: getPreloadPath(),
|
preload: getPreloadPath(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "electron-log/preload";
|
||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"types": "build/index.d.ts",
|
"types": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.32",
|
"version": "0.1.37",
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user