mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
85
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 |
@@ -124,7 +124,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
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
|
||||
@@ -1,5 +1,58 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
- **Always run typecheck after every change.**
|
||||
|
||||
## Orchestrator mode
|
||||
## Debugging
|
||||
|
||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
||||
- Launch agents in the most permissive mode
|
||||
- Set cwd to the repository root
|
||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
||||
|
||||
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||
|
||||
@@ -4,40 +4,88 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## 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
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
Run the daemon on any machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
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:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [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
|
||||
|
||||
Quick monorepo package map:
|
||||
@@ -57,8 +105,12 @@ npm run dev
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
+1
-1
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
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:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
- `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
|
||||
- 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
|
||||
|
||||
- [ ] 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 `Android APK Release` 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",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -16718,6 +16718,15 @@
|
||||
"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": {
|
||||
"version": "26.8.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
||||
@@ -34842,16 +34851,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.37",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -34937,6 +34946,8 @@
|
||||
},
|
||||
"packages/app/node_modules/expo-clipboard": {
|
||||
"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",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -34956,6 +34967,8 @@
|
||||
},
|
||||
"packages/app/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -34963,11 +34976,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -34987,6 +35000,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -34997,6 +35012,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35004,10 +35021,12 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/cli": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -35040,7 +35059,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35241,7 +35260,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35267,7 +35286,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35283,13 +35302,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -35334,6 +35353,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
@@ -35355,6 +35376,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -35369,6 +35392,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
@@ -35415,6 +35440,8 @@
|
||||
},
|
||||
"packages/server/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
@@ -35426,6 +35453,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ajv": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -35440,6 +35469,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35450,6 +35481,8 @@
|
||||
},
|
||||
"packages/server/node_modules/body-parser": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
@@ -35468,6 +35501,8 @@
|
||||
},
|
||||
"packages/server/node_modules/content-disposition": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
@@ -35478,6 +35513,8 @@
|
||||
},
|
||||
"packages/server/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
@@ -35485,6 +35522,8 @@
|
||||
},
|
||||
"packages/server/node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
@@ -35500,6 +35539,8 @@
|
||||
},
|
||||
"packages/server/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35507,6 +35548,8 @@
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35514,6 +35557,8 @@
|
||||
},
|
||||
"packages/server/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35524,6 +35569,8 @@
|
||||
},
|
||||
"packages/server/node_modules/mime-types": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
@@ -35534,6 +35581,8 @@
|
||||
},
|
||||
"packages/server/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -35572,6 +35621,8 @@
|
||||
},
|
||||
"packages/server/node_modules/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
@@ -35592,6 +35643,8 @@
|
||||
},
|
||||
"packages/server/node_modules/serve-static": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -35605,6 +35658,8 @@
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"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",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -35618,6 +35673,8 @@
|
||||
},
|
||||
"packages/server/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -35630,6 +35687,8 @@
|
||||
},
|
||||
"packages/server/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -35637,7 +35696,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
@@ -35663,6 +35722,8 @@
|
||||
},
|
||||
"packages/website/node_modules/@types/node": {
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+16
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -49,13 +49,13 @@
|
||||
"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",
|
||||
"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:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:major": "npm run version:all:major && 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 release:check && npm run version:all:minor && 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:patch": "npm run version:all:patch && npm run release:check && 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:major": "npm run version:all:major && 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 release:check && npm run version:all:minor && 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": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
@@ -68,6 +68,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"homepage": "https://paseo.sh",
|
||||
"keywords": [
|
||||
"openai",
|
||||
"realtime",
|
||||
@@ -76,7 +77,14 @@
|
||||
"development",
|
||||
"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",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
|
||||
@@ -448,12 +448,15 @@ export default async function globalSetup() {
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_VOICE_MODE_ENABLED: "1",
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
}
|
||||
: {}),
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
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",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.37",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@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,
|
||||
useHostRuntimeClient,
|
||||
} 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 type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
let cancelled = false;
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
const init = async () => {
|
||||
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(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}, [isLoading, settings.theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingView />;
|
||||
const isDesktopManaged =
|
||||
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
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;
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
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(() => {
|
||||
if (!onlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
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);
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,23 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
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;
|
||||
}, [daemons, preferences.serverId]);
|
||||
}, [daemons]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, 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>
|
||||
);
|
||||
}
|
||||
}, [router, targetServerId]);
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
|
||||
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
|
||||
function getModeColor(modeId?: string): string {
|
||||
if (!modeId) return "#9ca3af"; // gray
|
||||
|
||||
// Color based on common mode types
|
||||
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
|
||||
if (modeId.includes("code")) return "#22c55e"; // green - writes code
|
||||
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
|
||||
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
|
||||
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
|
||||
return "#3b82f6"; // blue - build/auto
|
||||
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
|
||||
}
|
||||
|
||||
@@ -537,14 +537,10 @@ export function AgentConfigRow({
|
||||
}, [modeOptions]);
|
||||
|
||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
||||
for (const model of models) {
|
||||
opts.push({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
return models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
||||
@@ -586,7 +582,7 @@ export function AgentConfigRow({
|
||||
title="Select model"
|
||||
value={selectedModel}
|
||||
options={modelSelectOptions}
|
||||
placeholder="Auto"
|
||||
placeholder={isModelLoading ? "Loading..." : "Select model"}
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
@@ -777,10 +773,8 @@ export function ModelDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedLabel = selectedModel
|
||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
||||
: "Automatic";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
||||
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||
const helperText = error
|
||||
? undefined
|
||||
: isLoading
|
||||
@@ -790,34 +784,20 @@ export function ModelDropdown({
|
||||
: undefined;
|
||||
|
||||
const options = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [
|
||||
{
|
||||
id: "",
|
||||
label: "Automatic (provider default)",
|
||||
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;
|
||||
return models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (id === "") {
|
||||
onClear();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onSelect(id);
|
||||
},
|
||||
[onClear, onSelect],
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
@@ -59,6 +59,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -91,6 +92,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
@@ -112,8 +114,6 @@ export function AgentInputArea({
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
const isScreenFocused = useIsFocused();
|
||||
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
@@ -127,7 +127,14 @@ export function AgentInputArea({
|
||||
agentDirectoryStatus === "revalidating" ||
|
||||
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) =>
|
||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||
@@ -260,7 +267,6 @@ export function AgentInputArea({
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
await client.sendAgentMessage(agentId, text, {
|
||||
messageId: clientMessageId,
|
||||
@@ -274,41 +280,8 @@ export function AgentInputArea({
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
}, [onSubmitMessage]);
|
||||
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
||||
|
||||
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 isAgentRunning = agentState.status === "running";
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
@@ -350,7 +323,7 @@ export function AgentInputArea({
|
||||
message,
|
||||
imageAttachments,
|
||||
forceSend,
|
||||
isAgentRunning: agent?.status === "running",
|
||||
isAgentRunning: agentState.status === "running",
|
||||
// Parent-managed submits are still valid submit paths even when the
|
||||
// transport is disconnected, because the parent decides the failure mode.
|
||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||
@@ -424,7 +397,7 @@ export function AgentInputArea({
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isScreenFocused) {
|
||||
if (!isInputActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -460,7 +433,7 @@ export function AgentInputArea({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isScreenFocused],
|
||||
[isInputActive],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -472,9 +445,9 @@ export function AgentInputArea({
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isScreenFocused,
|
||||
enabled: isInputActive,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isScreenFocused,
|
||||
isActive: () => isInputActive,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
@@ -483,7 +456,7 @@ export function AgentInputArea({
|
||||
});
|
||||
|
||||
function handleCancelAgent() {
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
if (!isAgentRunning || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !client) {
|
||||
@@ -497,7 +470,7 @@ export function AgentInputArea({
|
||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||
|
||||
const handleToggleRealtimeVoice = useCallback(() => {
|
||||
if (!voice || !isConnected || !agent) {
|
||||
if (!voice || !isConnected || !hasAgent) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
@@ -514,7 +487,7 @@ export function AgentInputArea({
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}, [agent, agentId, isConnected, serverId, toast, voice]);
|
||||
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
@@ -606,7 +579,7 @@ export function AgentInputArea({
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent && agent ? (
|
||||
{!isVoiceModeForAgent && hasAgent ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
@@ -716,7 +689,7 @@ export function AgentInputArea({
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isScreenFocused={isScreenFocused}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
|
||||
try {
|
||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.clearDraft("sent");
|
||||
input.setIsProcessing(false);
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
|
||||
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty and default values as unset", () => {
|
||||
it("treats empty values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
expect(normalizeModelId(" default ")).toBeNull();
|
||||
expect(normalizeModelId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trimmed model ids", () => {
|
||||
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.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 { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||
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 { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
@@ -104,21 +106,18 @@ function getModeIconColor(
|
||||
palette: {
|
||||
blue: { 500: string };
|
||||
green: { 500: string };
|
||||
amber: { 500: string };
|
||||
red: { 500: string };
|
||||
purple: { 500: string };
|
||||
},
|
||||
): string {
|
||||
switch (colorTier) {
|
||||
case "default":
|
||||
return palette.blue[500];
|
||||
case "safe":
|
||||
return palette.green[500];
|
||||
case "moderate":
|
||||
return palette.amber[500];
|
||||
return palette.blue[500];
|
||||
case "dangerous":
|
||||
return palette.red[500];
|
||||
case "readonly":
|
||||
case "planning":
|
||||
return palette.purple[500];
|
||||
default:
|
||||
return palette.blue[500];
|
||||
@@ -166,8 +165,12 @@ function ControlledStatusBar({
|
||||
const displayModel =
|
||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||
? "Loading models..."
|
||||
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
||||
: findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||
const displayThinking = findOptionLabel(
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||
);
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||
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) {
|
||||
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 modelsQuery = useQuery({
|
||||
@@ -631,23 +655,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const models = modelsQuery.data ?? null;
|
||||
|
||||
const displayMode =
|
||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
agent?.currentModeId ||
|
||||
"default";
|
||||
|
||||
const modelSelection = resolveAgentModelSelection({
|
||||
models,
|
||||
runtimeModelId: agent?.runtimeInfo?.model,
|
||||
runtimeModelId: agent?.runtimeModelId,
|
||||
configuredModelId: agent?.model,
|
||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||
});
|
||||
|
||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||
return (agent?.availableModes ?? []).map((mode) => ({
|
||||
return availableModes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}));
|
||||
}, [agent?.availableModes]);
|
||||
}, [availableModes]);
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
@@ -668,9 +692,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
onSelectMode={(modeId) => {
|
||||
@@ -777,7 +799,7 @@ export function DraftAgentStatusBar({
|
||||
label: definition.label,
|
||||
}));
|
||||
|
||||
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
||||
const modelOptions: StatusOption[] = [];
|
||||
for (const model of models) {
|
||||
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 {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
|
||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||
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 =
|
||||
models && preferredModelId
|
||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
||||
: null;
|
||||
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||
: fallbackModel;
|
||||
|
||||
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 selectedThinkingId =
|
||||
const resolvedThinkingId =
|
||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||
? explicitThinkingOptionId
|
||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||
const selectedThinking =
|
||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
||||
const displayThinking =
|
||||
selectedThinking?.label ??
|
||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
||||
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
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 { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
agent: Agent;
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
|
||||
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
|
||||
|
||||
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 {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
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);
|
||||
return model?.label ?? "Auto";
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsDesktopMac,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
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
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
|
||||
const collapsedSidebarInset =
|
||||
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||
? trafficLightPadding
|
||||
: { left: 0, right: 0 };
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
@@ -42,7 +43,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
{
|
||||
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
|
||||
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
|
||||
},
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
|
||||
@@ -681,7 +681,7 @@ function DesktopSidebar({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
{trafficLightPadding.side === 'left' ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
|
||||
@@ -66,8 +66,8 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
||||
isScreenFocused?: boolean;
|
||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** 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,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isScreenFocused = true,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
[onChangeText, onSubmit, images, isAgentRunning],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback((error: Error) => {
|
||||
console.error("[MessageInput] Dictation error:", error);
|
||||
}, []);
|
||||
const handleDictationError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error("[MessageInput] Dictation error:", error);
|
||||
toast.error(error.message);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
@@ -387,7 +391,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
||||
autoStopWhenHidden: { isVisible: isInputActive },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
@@ -950,18 +954,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View style={styles.leftButtonGroup}>
|
||||
{onPickImages && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -1203,7 +1209,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
leftButtonGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={false}
|
||||
selectable={Platform.OS === "web" ? undefined : false}
|
||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
} from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -392,15 +392,15 @@ function NewWorktreeButton({
|
||||
}}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Plus
|
||||
size={14}
|
||||
<FolderPlus
|
||||
size={15}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
@@ -411,7 +411,7 @@ function NewWorktreeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
|
||||
@@ -886,7 +886,10 @@ function SplitPaneView({
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
isFocusModeEnabled &&
|
||||
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
|
||||
trafficLightPadding.side && {
|
||||
paddingLeft: trafficLightPadding.left,
|
||||
paddingRight: trafficLightPadding.right,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
|
||||
@@ -150,70 +150,136 @@ export function Autocomplete({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDetail = removeBoltGlyphs(option.detail);
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onLayout={(event) => handleRowLayout(index, event)}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
{option.kind === "directory" || option.kind === "file" ? (
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
|
||||
</View>
|
||||
{optionDescription ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{optionDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.outerWrapper}>
|
||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailLabel}>
|
||||
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||
</Text>
|
||||
<Text style={styles.detailDescription}>
|
||||
{removeBoltGlyphs(selectedOption.description)}
|
||||
</Text>
|
||||
{selectedOption.detail ? (
|
||||
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const optionDescription = removeBoltGlyphs(option.description);
|
||||
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onLayout={(event) => handleRowLayout(index, event)}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
{isFileOrDir ? (
|
||||
<>
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === "directory" ? (
|
||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
outerWrapper: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
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,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemMainRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
itemDescriptionInline: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: 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 { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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 { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionTextPrimary: {
|
||||
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: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
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 {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
@@ -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(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<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}>
|
||||
{actions.map((action) => {
|
||||
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{showHostList && (
|
||||
<View style={styles.hostList}>
|
||||
{hosts.map((host) => (
|
||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<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_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)
|
||||
function isDesktopEnvironment(): boolean {
|
||||
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 { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getRightSidebarAnimationTargets,
|
||||
shouldSyncSidebarAnimation,
|
||||
} from "@/utils/sidebar-animation-state";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
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;
|
||||
|
||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
|
||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
const prevWindowWidth = useRef(windowWidth);
|
||||
|
||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||
useEffect(() => {
|
||||
// Skip if this is initial render or if we're mid-gesture
|
||||
if (prevIsOpen.current === isOpen) {
|
||||
return;
|
||||
}
|
||||
const didStateChange = shouldSyncSidebarAnimation({
|
||||
previousIsOpen: prevIsOpen.current,
|
||||
nextIsOpen: isOpen,
|
||||
previousWindowWidth: prevWindowWidth.current,
|
||||
nextWindowWidth: windowWidth,
|
||||
});
|
||||
const previousIsOpen = prevIsOpen.current;
|
||||
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
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
translateX.value = withTiming(0, {
|
||||
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
if (previousIsOpen !== isOpen) {
|
||||
translateX.value = withTiming(targets.translateX, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
backdropOpacity.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
} else {
|
||||
translateX.value = withTiming(windowWidth, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
backdropOpacity.value = withTiming(0, {
|
||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
translateX.value = targets.translateX;
|
||||
backdropOpacity.value = targets.backdropOpacity;
|
||||
}, [
|
||||
isOpen,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
isGesturing,
|
||||
mobileView,
|
||||
desktopFileExplorerOpen,
|
||||
]);
|
||||
|
||||
const animateToOpen = () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
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
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionAgents) {
|
||||
previousAgentStatusRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
for (const nextAgent of sessionAgents.values()) {
|
||||
nextStatuses.set(nextAgent.id, nextAgent.status);
|
||||
}
|
||||
previousAgentStatusRef.current = nextStatuses;
|
||||
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||
previousAgentStatusRef.current,
|
||||
sessionAgents,
|
||||
);
|
||||
}, [sessionAgents]);
|
||||
|
||||
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 { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
shouldSyncSidebarAnimation,
|
||||
} from "@/utils/sidebar-animation-state";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
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;
|
||||
|
||||
// Initialize based on current state
|
||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
const prevWindowWidth = useRef(windowWidth);
|
||||
|
||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||
useEffect(() => {
|
||||
// Skip if this is initial render or if we're mid-gesture
|
||||
if (prevIsOpen.current === isOpen) {
|
||||
const didStateChange = shouldSyncSidebarAnimation({
|
||||
previousIsOpen: prevIsOpen.current,
|
||||
nextIsOpen: isOpen,
|
||||
previousWindowWidth: prevWindowWidth.current,
|
||||
nextWindowWidth: windowWidth,
|
||||
});
|
||||
const previousIsOpen = prevIsOpen.current;
|
||||
prevIsOpen.current = isOpen;
|
||||
prevWindowWidth.current = windowWidth;
|
||||
|
||||
if (!didStateChange) {
|
||||
return;
|
||||
}
|
||||
prevIsOpen.current = isOpen;
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
translateX.value = withTiming(0, {
|
||||
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
if (previousIsOpen !== isOpen) {
|
||||
translateX.value = withTiming(targets.translateX, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
backdropOpacity.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
} else {
|
||||
translateX.value = withTiming(-windowWidth, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
backdropOpacity.value = withTiming(0, {
|
||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
translateX.value = targets.translateX;
|
||||
backdropOpacity.value = targets.backdropOpacity;
|
||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||
|
||||
const animateToOpen = useCallback(() => {
|
||||
|
||||
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
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(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
|
||||
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", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
|
||||
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(
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
|
||||
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(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -11,7 +11,11 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } 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
|
||||
export interface FormInitialValues {
|
||||
@@ -102,7 +106,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
@@ -117,6 +121,10 @@ function resolveDefaultModel(
|
||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveEffectiveModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
@@ -195,25 +225,22 @@ function resolveFormState(
|
||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
|
||||
if (initialModel) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
result.model = defaultModelId;
|
||||
}
|
||||
} 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)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
result.model = defaultModelId;
|
||||
}
|
||||
} else if (defaultModelId) {
|
||||
result.model = defaultModelId;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
@@ -224,13 +251,17 @@ function resolveFormState(
|
||||
typeof initialValues?.thinkingOptionId === "string"
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
|
||||
|
||||
if (!userModified.thinkingOptionId) {
|
||||
const effectiveModelId = result.model.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
|
||||
: "";
|
||||
|
||||
if (initialThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = initialThinkingOptionId;
|
||||
} else if (preferredThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = preferredThinkingOptionId;
|
||||
} else if (preferredThinking.length > 0) {
|
||||
result.thinkingOptionId = preferredThinking;
|
||||
} else {
|
||||
result.thinkingOptionId = "";
|
||||
}
|
||||
@@ -238,27 +269,17 @@ function resolveFormState(
|
||||
|
||||
// Validate thinking option once model metadata is available.
|
||||
if (availableModels) {
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
if (thinkingOptions.length === 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
result.thinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: result.model,
|
||||
requestedThinkingOptionId: result.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Resolve serverId (independent)
|
||||
// Only use stored serverId if the host still exists in the registry
|
||||
if (!userModified.serverId) {
|
||||
if (initialValues?.serverId !== undefined) {
|
||||
result.serverId = initialValues.serverId;
|
||||
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
|
||||
result.serverId = preferences.serverId;
|
||||
}
|
||||
// else keep current
|
||||
}
|
||||
@@ -267,8 +288,6 @@ function resolveFormState(
|
||||
if (!userModified.workingDir) {
|
||||
if (initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
} else if (preferences?.workingDir) {
|
||||
result.workingDir = preferences.workingDir;
|
||||
}
|
||||
// else keep current (empty string)
|
||||
}
|
||||
@@ -313,7 +332,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
preferences,
|
||||
isLoading: isPreferencesLoading,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
} = useFormPreferences();
|
||||
|
||||
const daemons = useHosts();
|
||||
@@ -537,107 +555,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
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
|
||||
const setSelectedServerIdFromUser = useCallback(
|
||||
(value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||
void updatePreferences({ serverId: value ?? undefined });
|
||||
},
|
||||
[updatePreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
setFormState((prev) => ({ ...prev, provider }));
|
||||
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 providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerDef = providerDefinitionMap.get(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) => ({
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
modeId: nextModeId,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
},
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
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) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: modelId,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
model: nextModelId,
|
||||
modeId: providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
void updatePreferences({ provider });
|
||||
void updateProviderPreferences(provider, { model: modelId });
|
||||
},
|
||||
[
|
||||
preferences?.providerPreferences,
|
||||
providerDefinitionMap,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
],
|
||||
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
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 }));
|
||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[availableModels, userModified.thinkingOptionId],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
@@ -648,9 +678,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
(value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||
void updatePreferences({ workingDir: value });
|
||||
},
|
||||
[updatePreferences],
|
||||
[],
|
||||
);
|
||||
|
||||
const setSelectedServerId = useCallback((value: string | null) => {
|
||||
@@ -662,39 +691,42 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [providerModelsQuery]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const providerPreferenceUpdates: {
|
||||
mode: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
} = {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const modelId = resolvedModel?.id ?? formState.model;
|
||||
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
|
||||
|
||||
const nextProviderPrefs: ProviderPreferences = {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
thinkingByModel: {
|
||||
...existingProviderPrefs?.thinkingByModel,
|
||||
...(modelId && formState.thinkingOptionId
|
||||
? { [modelId]: formState.thinkingOptionId }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
if (userModified.thinkingOptionId) {
|
||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
||||
}
|
||||
|
||||
await updatePreferences({
|
||||
workingDir: formState.workingDir,
|
||||
provider: formState.provider,
|
||||
serverId: formState.serverId ?? undefined,
|
||||
providerPreferences: {
|
||||
...preferences?.providerPreferences,
|
||||
[formState.provider]: nextProviderPrefs,
|
||||
},
|
||||
});
|
||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
||||
}, [
|
||||
formState.modeId,
|
||||
availableModels,
|
||||
formState.model,
|
||||
formState.modeId,
|
||||
formState.provider,
|
||||
formState.serverId,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
userModified.thinkingOptionId,
|
||||
preferences?.providerPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||
const modelError =
|
||||
@@ -711,7 +743,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
setProviderFromUser,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
selectedModel: resolvedModelId,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
@@ -737,7 +769,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
formState.serverId,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
resolvedModelId,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
@@ -771,5 +803,7 @@ export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
export const __private__ = {
|
||||
combineInitialValues,
|
||||
resolveDefaultModel,
|
||||
resolveFormState,
|
||||
resolveThinkingOptionId,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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 =
|
||||
| { kind: "idle" }
|
||||
@@ -8,8 +20,8 @@ export type AgentScreenMissingState =
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export interface AgentScreenMachineInput {
|
||||
agent: Agent | null;
|
||||
placeholderAgent: Agent | null;
|
||||
agent: AgentScreenAgent | null;
|
||||
placeholderAgent: AgentScreenAgent | null;
|
||||
missingAgentState: AgentScreenMissingState;
|
||||
isConnected: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
@@ -31,7 +43,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
hasRenderedReady: boolean;
|
||||
lastReadyAgent: Agent | null;
|
||||
lastReadyAgent: AgentScreenAgent | null;
|
||||
activeToastLatch: AgentScreenToastLatch;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}
|
||||
@@ -70,7 +82,7 @@ export type AgentScreenViewState =
|
||||
}
|
||||
| {
|
||||
tag: "ready";
|
||||
agent: Agent;
|
||||
agent: AgentScreenAgent;
|
||||
source: "authoritative" | "optimistic" | "stale";
|
||||
sync: AgentScreenReadySyncState;
|
||||
isArchiving: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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_QUERY_KEY = ["form-preferences"];
|
||||
@@ -10,13 +9,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
thinkingByModel: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
workingDir: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -35,12 +32,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
||||
export interface UseFormPreferencesReturn {
|
||||
preferences: FormPreferences;
|
||||
isLoading: boolean;
|
||||
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
|
||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||
updateProviderPreferences: (
|
||||
provider: AgentProvider,
|
||||
updates: Partial<ProviderPreferences>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
@@ -54,13 +46,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
|
||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||
|
||||
const getProviderPreferences = useCallback(
|
||||
(provider: AgentProvider): ProviderPreferences | undefined => {
|
||||
return preferences.providerPreferences?.[provider];
|
||||
},
|
||||
[preferences.providerPreferences],
|
||||
);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<FormPreferences>) => {
|
||||
const prev =
|
||||
@@ -73,32 +58,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
[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 {
|
||||
preferences,
|
||||
isLoading: isPending,
|
||||
getProviderPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
|
||||
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", () => {
|
||||
const calls: string[] = [];
|
||||
const action: KeyboardActionDefinition = {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
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 {
|
||||
useAgentScreenStateMachine,
|
||||
type AgentScreenAgent,
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
@@ -78,11 +81,21 @@ function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const agent = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null,
|
||||
const descriptorState = useSessionStore(
|
||||
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 label = resolveWorkspaceAgentTabLabel(agent?.title);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
|
||||
return {
|
||||
@@ -90,12 +103,12 @@ function useAgentPanelDescriptor(
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon,
|
||||
statusBucket: agent
|
||||
statusBucket: descriptorState.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
status: descriptorState.status,
|
||||
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
||||
requiresAttention: descriptorState.requiresAttention,
|
||||
attentionReason: descriptorState.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
@@ -243,8 +256,25 @@ function AgentPanelBody({
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const agent = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined,
|
||||
const agentState = useSessionStore(
|
||||
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) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
@@ -318,52 +348,14 @@ function AgentPanelBody({
|
||||
agentId,
|
||||
client,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
requiresAttention: agentState.requiresAttention,
|
||||
attentionReason: agentState.attentionReason,
|
||||
isScreenFocused: isPaneFocused,
|
||||
});
|
||||
useEffect(() => {
|
||||
clearOnAgentBlurRef.current = 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({
|
||||
mode: "translate",
|
||||
});
|
||||
@@ -488,49 +480,34 @@ function AgentPanelBody({
|
||||
}, [optimisticStreamItems, streamItems]);
|
||||
|
||||
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
|
||||
const authoritativeStatus = agent?.status;
|
||||
const authoritativeStatus = agentState.status;
|
||||
const isAuthoritativeBootstrapping =
|
||||
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
||||
const showPendingCreateSubmitLoading =
|
||||
isPendingCreateForPanel && (!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) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
return {
|
||||
serverId,
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
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: ".",
|
||||
model: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
}, [agentId, serverId, shouldUseOptimisticStream]);
|
||||
|
||||
@@ -642,7 +619,7 @@ function AgentPanelBody({
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agent || shouldUseOptimisticStream) {
|
||||
if (agentState.id || shouldUseOptimisticStream) {
|
||||
if (missingAgentState.kind !== "idle") {
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}
|
||||
@@ -717,7 +694,7 @@ function AgentPanelBody({
|
||||
setMissingAgentState({ kind: "error", message });
|
||||
});
|
||||
}, [
|
||||
agent,
|
||||
agentState.id,
|
||||
agentId,
|
||||
client,
|
||||
ensureAgentIsInitialized,
|
||||
@@ -803,10 +780,11 @@ function AgentPanelBody({
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
|
||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||
<AgentInputArea
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
images={agentInputDraft.images}
|
||||
@@ -831,7 +809,7 @@ function AgentPanelBody({
|
||||
streamViewRef.current?.scrollToBottom("message-sent");
|
||||
}}
|
||||
/>
|
||||
) : agentId && agent?.archivedAt ? (
|
||||
) : agentId && agentState.archivedAt ? (
|
||||
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -17,8 +17,15 @@ function useDraftPanelDescriptor() {
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
|
||||
usePaneContext();
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
target,
|
||||
isPaneFocused,
|
||||
openFileInWorkspace,
|
||||
retargetCurrentTab,
|
||||
} = usePaneContext();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
return (
|
||||
@@ -27,6 +34,7 @@ function DraftPanel() {
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onOpenWorkspaceFile={({ filePath }) => {
|
||||
openFileInWorkspace(filePath);
|
||||
}}
|
||||
|
||||
@@ -1069,7 +1069,8 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
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_TIMEOUT_MS = 2500;
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -1152,30 +1153,43 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapDesktop();
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapDesktop(): Promise<void> {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
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;
|
||||
} 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,
|
||||
});
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? daemon.hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
|
||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||
|
||||
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 cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
|
||||
provider: selectedProvider,
|
||||
cwd,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||
...(selectedThinkingOptionId.trim()
|
||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
selectedWorktreePath,
|
||||
workingDir,
|
||||
]);
|
||||
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
|
||||
if (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) {
|
||||
return "Select a worktree to attach";
|
||||
}
|
||||
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
|
||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||
".";
|
||||
const provider = selectedProvider;
|
||||
const model = selectedModel.trim() || null;
|
||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
|
||||
return {
|
||||
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const effectiveBaseBranch = baseBranch.trim();
|
||||
@@ -1199,6 +1219,7 @@ function DraftAgentScreenContent({
|
||||
<AgentInputArea
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isInputActive={isFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -19,8 +18,10 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const collapsedSidebarInset =
|
||||
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||
? trafficLightPadding
|
||||
: { left: 0, right: 0 };
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +32,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
|
||||
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
|
||||
const handleThemeChange = useCallback(
|
||||
(newTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: newTheme });
|
||||
if (newTheme === "auto") {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
} else {
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(newTheme);
|
||||
}
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
@@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = {
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
isPaneFocused: boolean;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
||||
};
|
||||
@@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
isPaneFocused,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
@@ -92,6 +95,22 @@ export function WorkspaceDraftAgentTab({
|
||||
setWorkingDir(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 {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
@@ -108,6 +127,12 @@ export function WorkspaceDraftAgentTab({
|
||||
if (providerDefinitions.length === 0) {
|
||||
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) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
@@ -122,8 +147,8 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
buildDraftAgent: (attempt) => {
|
||||
const now = attempt.timestamp;
|
||||
const model = selectedModel.trim() || null;
|
||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
return {
|
||||
serverId,
|
||||
@@ -153,14 +178,14 @@ export function WorkspaceDraftAgentTab({
|
||||
}
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
@@ -186,17 +211,17 @@ export function WorkspaceDraftAgentTab({
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||
...(selectedThinkingOptionId.trim()
|
||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
@@ -243,6 +268,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentInputArea
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
@@ -251,7 +277,7 @@ export function WorkspaceDraftAgentTab({
|
||||
images={draftInput.images}
|
||||
onChangeImages={draftInput.setImages}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={!isSubmitting}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
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 { Fonts } from "@/constants/theme";
|
||||
|
||||
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
*
|
||||
@@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
body: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
@@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
text: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
@@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
heading1: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["3xl"],
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading2: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading3: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading4: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading5: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
@@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
heading6: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
strong: {
|
||||
...webSelectableTextStyle,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
|
||||
em: {
|
||||
...webSelectableTextStyle,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
|
||||
s: {
|
||||
...webSelectableTextStyle,
|
||||
textDecorationLine: "line-through" as const,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
link: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
@@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
blocklink: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
@@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
code_inline: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
@@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
code_block: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
@@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
fence: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
@@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
tbody: {},
|
||||
|
||||
th: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
@@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
td: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
bullet_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
ordered_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
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 {
|
||||
getIsDesktopMac,
|
||||
getIsDesktop,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
@@ -120,11 +123,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
}, [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);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsDesktop()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
@@ -175,12 +178,23 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsDesktopMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
if (!getIsDesktop() || isFullscreen) {
|
||||
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 {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
left: 0,
|
||||
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");
|
||||
});
|
||||
|
||||
it("treats legacy 'default' model ids as unset", () => {
|
||||
it("preserves 'default' as a valid model id", () => {
|
||||
const agent = {
|
||||
model: "default",
|
||||
runtimeInfo: { model: "default" },
|
||||
} 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;
|
||||
if (typeof runtimeModel === "string") {
|
||||
const normalized = runtimeModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
if (typeof fallbackModel === "string") {
|
||||
const normalized = fallbackModel.trim();
|
||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
||||
if (normalized.length > 0) {
|
||||
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(() => {
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"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 { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.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 { withOutput } from "./output/index.js";
|
||||
import { onboardCommand } from "./commands/onboard.js";
|
||||
@@ -93,6 +94,10 @@ export function createCli(): Command {
|
||||
addWaitOptions(program.command("wait")),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
addArchiveOptions(program.command("archive")),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand());
|
||||
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 type {
|
||||
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 {
|
||||
force?: boolean;
|
||||
host?: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { runModeCommand } from "./mode.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { addArchiveOptions, runArchiveCommand } from "./archive.js";
|
||||
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
|
||||
import { addLsOptions, runLsCommand } from "./ls.js";
|
||||
import { addRunOptions, runRunCommand } from "./run.js";
|
||||
@@ -69,11 +69,7 @@ export function createAgentCommand(): Command {
|
||||
).action(withOutput(runModeCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.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)"),
|
||||
addArchiveOptions(agent.command("archive")),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addLsOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -193,11 +194,7 @@ export async function runLsCommand(
|
||||
|
||||
// Optional cwd filter.
|
||||
if (options.cwd) {
|
||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
||||
agents = agents.filter((a) => {
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
|
||||
// Apply label filtering only when explicitly requested.
|
||||
|
||||
@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
};
|
||||
|
||||
export interface AgentSendOptions extends CommandOptions {
|
||||
noWait?: boolean;
|
||||
wait?: boolean;
|
||||
image?: string[];
|
||||
prompt?: string;
|
||||
promptFile?: string;
|
||||
@@ -193,7 +193,7 @@ export async function runSendCommand(
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||
|
||||
// If --no-wait, return immediately
|
||||
if (options.noWait) {
|
||||
if (options.wait === false) {
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -75,12 +76,9 @@ export async function runStopCommand(
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
// Stop agents in directory
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
// Stop specific agent
|
||||
|
||||
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null;
|
||||
@@ -12,17 +13,14 @@ function normalizeError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: `ps failed: ${normalizeError(result.error)}`,
|
||||
};
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
@@ -34,12 +32,36 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
|
||||
|
||||
// Find the worktree by name or branch
|
||||
const worktree = listResponse.worktrees.find((wt) => {
|
||||
const name = wt.worktreePath.split("/").pop();
|
||||
const name = path.basename(wt.worktreePath);
|
||||
return name === nameArg || wt.branchName === nameArg;
|
||||
});
|
||||
|
||||
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
||||
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
|
||||
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
|
||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||
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)) {
|
||||
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("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 ===");
|
||||
|
||||
@@ -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("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");
|
||||
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
|
||||
)
|
||||
|
||||
set "PASEO_DESKTOP_CLI=1"
|
||||
"%APP_EXECUTABLE%" %*
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%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%
|
||||
|
||||
@@ -14,6 +14,7 @@ publish:
|
||||
owner: getpaseo
|
||||
repo: paseo
|
||||
mac:
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
category: public.app-category.developer-tools
|
||||
icon: assets/icon.icns
|
||||
hardenedRuntime: true
|
||||
@@ -30,11 +31,15 @@ linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
maintainer: "Mohamed Boudra <hello@moboudra.com>"
|
||||
vendor: "Paseo"
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
- tar.gz
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,9 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/cli": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -25,5 +26,15 @@
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"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 "$@"
|
||||
`;
|
||||
|
||||
// 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) {
|
||||
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");
|
||||
if (fs.existsSync(chromeSandbox)) {
|
||||
|
||||
@@ -148,8 +148,6 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
|
||||
origins.add("paseo://app");
|
||||
|
||||
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
|
||||
try {
|
||||
const parsed = new URL(devServerUrl);
|
||||
@@ -189,7 +187,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -544,12 +543,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
return downloadAndInstallUpdate(currentVersion);
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import log from "electron-log/main";
|
||||
log.initialize({ spyRendererConsole: true });
|
||||
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -89,8 +92,17 @@ async function createMainWindow(): Promise<void> {
|
||||
height: 800,
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: isMac ? "hidden" : "default",
|
||||
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
|
||||
titleBarStyle: "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 48,
|
||||
},
|
||||
autoHideMenuBar: true,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "electron-log/preload";
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# Voice Assistant System Prompt
|
||||
|
||||
## 1. Core Voice Rules (NON-NEGOTIABLE)
|
||||
|
||||
### Voice Context
|
||||
|
||||
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
|
||||
|
||||
### Voice Message Envelope
|
||||
|
||||
Some user utterances will be wrapped in an XML tag like:
|
||||
|
||||
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
|
||||
|
||||
- Treat the inner text as what the user said (STT output).
|
||||
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
|
||||
|
||||
**Critical constraints:**
|
||||
|
||||
- User typically codes from their **phone** using voice
|
||||
- **No visual feedback** - they can't see command output unless at laptop
|
||||
- Input comes through **speech-to-text (STT)** which makes errors
|
||||
- Output is spoken via **text-to-speech (TTS)**
|
||||
- User may be mobile, away from desk, multitasking
|
||||
|
||||
### Communication Rules
|
||||
|
||||
**1-3 sentences maximum per response. Always.**
|
||||
|
||||
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
|
||||
- **Progressive disclosure** - answer what's asked, let user ask for more
|
||||
- **Start high-level** - give the gist, not every detail
|
||||
- **Natural pauses** - leave room for user to respond or redirect
|
||||
|
||||
**Good example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
|
||||
|
||||
User: "How's the auth agent doing?"
|
||||
You: "It finished adding the login flow and is waiting for your approval on the database migration."
|
||||
```
|
||||
|
||||
**Bad example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
|
||||
```
|
||||
|
||||
### Handling STT Errors
|
||||
|
||||
Speech-to-text makes mistakes. Fix them silently using context.
|
||||
|
||||
**Common errors:**
|
||||
|
||||
- Homophones: "list" → "missed", "code" → "load"
|
||||
- Project names: "faro" → "pharaoh", "mcp" → "empty"
|
||||
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
|
||||
|
||||
**How to handle:**
|
||||
|
||||
1. Use context to fix obvious mistakes silently
|
||||
2. Ask for clarification only when truly ambiguous
|
||||
3. Never lecture about the error - just handle it
|
||||
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
|
||||
|
||||
**Examples:**
|
||||
|
||||
- User: "Run empty install" → Interpret as "Run npm install"
|
||||
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
|
||||
|
||||
### Immediate Silence Protocol
|
||||
|
||||
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
|
||||
|
||||
- "I'm not talking to you"
|
||||
- "Shut up" / "Be quiet" / "Stop talking"
|
||||
- "Not you"
|
||||
|
||||
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
|
||||
|
||||
## 2. Delegation Pattern
|
||||
|
||||
### Core Rule: Always Work Through Coding Agents
|
||||
|
||||
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
|
||||
|
||||
### Safe Operations (Execute Immediately)
|
||||
|
||||
These orchestration tools only read or summarize state:
|
||||
|
||||
- `list_agents()` – discover who exists before delegating
|
||||
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
|
||||
- `wait_for_agent()` – block until an agent requests permission or completes the current run
|
||||
|
||||
Call them without asking when context requires it. Never fabricate their output.
|
||||
|
||||
### Delegated Operations (Announce + Execute)
|
||||
|
||||
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
|
||||
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
|
||||
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
|
||||
|
||||
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
|
||||
|
||||
### When to Ask vs Act
|
||||
|
||||
Ask only when the routing decision is truly ambiguous. Otherwise:
|
||||
|
||||
- Default to the most recently addressed agent.
|
||||
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
|
||||
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
|
||||
|
||||
### Tool Results Reporting
|
||||
|
||||
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
|
||||
|
||||
## 4. Agent Integrations
|
||||
|
||||
### Your Role: Orchestrator
|
||||
|
||||
You orchestrate work. Agents execute.
|
||||
|
||||
**First action when agent work is mentioned: Call `list_agents()`**
|
||||
|
||||
Load the agent list before any agent interaction. Always.
|
||||
|
||||
#### Focus Management
|
||||
|
||||
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
|
||||
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
|
||||
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
|
||||
- Always narrate hand-offs: “Okay, handing that to Planner.”
|
||||
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
|
||||
|
||||
**Confirm before destructive agent operations:**
|
||||
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
|
||||
- Killing agents: "Kill agent [id] working on [task]?"
|
||||
|
||||
**Delegate vs execute:**
|
||||
- Everything touching code, git, or shell runs through agents
|
||||
- Keep lightweight questions or summaries in-orchestrator when no action is needed
|
||||
- Active agent context → Send prompt to that agent (respect focus)
|
||||
|
||||
### Available Agents (Source of Truth)
|
||||
|
||||
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
|
||||
|
||||
**Claude Code (`claude`)**
|
||||
- Default mode: `plan`
|
||||
- Alternate mode: `bypassPermissions`
|
||||
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
|
||||
|
||||
**Codex (`codex`)**
|
||||
- Default mode: `auto`
|
||||
- Other modes: `read-only`, `full-access`
|
||||
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
|
||||
|
||||
### Creating Agents
|
||||
|
||||
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
|
||||
|
||||
```javascript
|
||||
// Claude Code with planning
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add dark mode toggle to settings page",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// Codex for quick edits
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "codex",
|
||||
initialPrompt: "clean up the logging",
|
||||
initialMode: "auto"
|
||||
})
|
||||
```
|
||||
|
||||
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
|
||||
|
||||
### Working with Agents
|
||||
|
||||
**Send prompts to agents:**
|
||||
|
||||
```javascript
|
||||
// Send task (non-blocking by default)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "explain how authentication works"
|
||||
})
|
||||
// Returns immediately, agent processes in background
|
||||
|
||||
// Send task and wait for completion
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "fix the bug in auth.ts",
|
||||
maxWait: 60000 // Wait up to 60 seconds
|
||||
})
|
||||
|
||||
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "implement user registration",
|
||||
sessionMode: "bypassPermissions"
|
||||
})
|
||||
```
|
||||
|
||||
**Check agent status:**
|
||||
|
||||
```javascript
|
||||
// Get current status
|
||||
get_agent_status({ agentId: "abc123" })
|
||||
// Returns: { status: "processing", info: {...} }
|
||||
|
||||
// Get agent activity (curated, human-readable)
|
||||
get_agent_activity({
|
||||
agentId: "abc123",
|
||||
format: "curated" // Clean summary of what agent did
|
||||
})
|
||||
|
||||
// List all agents
|
||||
list_agents()
|
||||
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
|
||||
```
|
||||
|
||||
**Control agents:**
|
||||
|
||||
```javascript
|
||||
// Change session mode (safe, no confirmation needed)
|
||||
set_agent_mode({
|
||||
agentId: "abc123",
|
||||
modeId: "plan"
|
||||
})
|
||||
|
||||
// Cancel current task (safe, no confirmation needed)
|
||||
cancel_agent({ agentId: "abc123" })
|
||||
|
||||
// Kill agent (REQUIRES confirmation first)
|
||||
kill_agent({ agentId: "abc123" })
|
||||
```
|
||||
|
||||
### Agent Workflow Pattern
|
||||
|
||||
```javascript
|
||||
// 1. Load agents first
|
||||
list_agents()
|
||||
|
||||
// 2. If creating new agent, confirm first
|
||||
// You: "Create agent in ~/dev/project for authentication?"
|
||||
// User: "yes"
|
||||
|
||||
// 3. Create with type + mode
|
||||
create_agent({
|
||||
cwd: "~/dev/project",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add authentication",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// 4. Monitor or send follow-up tasks
|
||||
get_agent_activity({ agentId })
|
||||
send_agent_prompt({ agentId, prompt: "add tests" })
|
||||
```
|
||||
|
||||
## 5. Git & GitHub
|
||||
|
||||
### Git Worktree Utilities
|
||||
|
||||
Custom utilities for safe worktree management:
|
||||
|
||||
**create-worktree:**
|
||||
- Creates new git worktree with new branch
|
||||
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
|
||||
- Outputs WORKTREE_PATH for you to parse
|
||||
|
||||
**delete-worktree:**
|
||||
- Preserves the branch, only deletes directory
|
||||
- Safe to use - won't lose work
|
||||
- Run from within worktree directory
|
||||
|
||||
### GitHub CLI (gh)
|
||||
|
||||
Already authenticated. Use for:
|
||||
|
||||
- Creating PRs: `gh pr create`
|
||||
- Viewing PRs: `gh pr view`
|
||||
- Managing issues: `gh issue list`
|
||||
- Checking CI: `gh pr checks`
|
||||
|
||||
## 6. Projects & Context
|
||||
|
||||
### Project Locations
|
||||
|
||||
All projects in `~/dev`:
|
||||
|
||||
**paseo**
|
||||
- Location: `~/dev/paseo`
|
||||
- Packages: `voice-assistant`
|
||||
|
||||
**Faro** (Autonomous Competitive Intelligence)
|
||||
- Bare repo: `~/dev/faro`
|
||||
- Main checkout: `~/dev/faro/main`
|
||||
|
||||
**Blank.page** (Minimal browser text editor)
|
||||
- Location: `~/dev/blank.page/editor`
|
||||
|
||||
### Decision Rules
|
||||
|
||||
**Agent work mentioned?**
|
||||
1. Call `list_agents()` first
|
||||
2. Reuse existing agent if task relates to its work
|
||||
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
|
||||
|
||||
**Creating/killing agents?**
|
||||
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
|
||||
- Ask: "Kill agent [id]?"
|
||||
- Wait for "yes"
|
||||
|
||||
**Task routing:**
|
||||
- All coding tasks → Delegate to an agent
|
||||
- Active agent + related work → Delegate to that agent
|
||||
- If the user explicitly mentions another agent, switch focus before delegating
|
||||
|
||||
**Context tracking:**
|
||||
- Track active agents and their directories
|
||||
- Use conversation context to resolve ambiguity
|
||||
- Fix STT errors silently
|
||||
- Maintain a recency-based focus pointer and narrate any focus change out loud
|
||||
|
||||
### Core Reminders
|
||||
|
||||
- Call actual tools, never just describe
|
||||
- 1-3 sentences max per response
|
||||
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
|
||||
- Default to action when context is clear
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.37",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -13,8 +13,7 @@
|
||||
"dist/scripts",
|
||||
"src/server/speech/providers/local/sherpa/assets",
|
||||
"README.md",
|
||||
".env.example",
|
||||
"agent-prompt.md"
|
||||
".env.example"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -64,8 +63,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -73,6 +73,29 @@ describe("curateAgentActivity", () => {
|
||||
expect(result).toContain("[Shell] npm test");
|
||||
});
|
||||
|
||||
it("renders terminal tool calls as one-line command summaries", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: `skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line
|
||||
|
||||
second line'`,
|
||||
icon: "square_terminal",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
|
||||
expect(result).toContain(
|
||||
"[Terminal] skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line second line'",
|
||||
);
|
||||
expect(result).not.toContain("[Interacted with terminal]");
|
||||
});
|
||||
|
||||
it("does not infer summary from raw input when detail is missing", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
toolCallItem({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 200;
|
||||
|
||||
function appendText(buffer: string, text: string): string {
|
||||
const normalized = text.trim();
|
||||
@@ -45,6 +46,20 @@ function formatToolInputJson(input: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolSummary(summary: string | undefined): string | null {
|
||||
if (typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = summary.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length <= MAX_TOOL_SUMMARY_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_TOOL_SUMMARY_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -215,7 +230,7 @@ export function curateAgentActivity(
|
||||
metadata: item.metadata,
|
||||
});
|
||||
const displayName = display.displayName;
|
||||
const summary = display.summary;
|
||||
const summary = formatToolSummary(display.summary);
|
||||
if (isLikelyExternalToolName(item.name) && inputJson) {
|
||||
lines.push(`[${displayName}] ${inputJson}`);
|
||||
break;
|
||||
|
||||
@@ -137,10 +137,7 @@ function startAgentRun(
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const shouldReplace =
|
||||
options?.replaceRunning &&
|
||||
Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun));
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
@@ -527,7 +524,7 @@ export async function createAgentManagementMcpServer(
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
|
||||
if (agentManager.hasInFlightRun(agentId)) {
|
||||
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user