Compare commits

..
Author SHA1 Message Date
Mohamed Boudra c28d917a4b Simplify prompt shortcut handling 2026-03-24 11:14:13 +07:00
Mohamed Boudra 2f6b8cea01 Add number key shortcuts for agent prompts 2026-03-23 21:56:39 +07:00
311 changed files with 9060 additions and 21811 deletions
-3
View File
@@ -26,9 +26,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app
-2
View File
@@ -9,8 +9,6 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
+3 -3
View File
@@ -124,7 +124,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -230,7 +230,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +339,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
-45
View File
@@ -1,45 +0,0 @@
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
-59
View File
@@ -1,59 +0,0 @@
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
-3
View File
@@ -36,9 +36,6 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
-71
View File
@@ -1,76 +1,5 @@
# Changelog
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
- The notification test button now surfaces errors instead of failing silently.
## 0.1.32 - 2026-03-23
### Added
+5 -3
View File
@@ -46,7 +46,9 @@ 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.**
## Debugging
## Orchestrator mode
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
- 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
+10 -74
View File
@@ -4,100 +4,40 @@
<h1 align="center">Paseo</h1>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<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%">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" 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.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
- **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.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
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.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Then connect from the desktop app, mobile app, or CLI.
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:
@@ -117,12 +57,8 @@ 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
View File
@@ -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 hello@moboudra.com. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
-8
View File
@@ -31,14 +31,6 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
+3 -16
View File
@@ -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,7 +25,6 @@ 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
```
@@ -33,7 +32,6 @@ 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
@@ -64,23 +62,12 @@ 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
- 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.
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` 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
View File
@@ -1,27 +0,0 @@
{
"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
}
-59
View File
@@ -1,59 +0,0 @@
{
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
View File
@@ -1,172 +0,0 @@
{
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
View File
@@ -1,144 +0,0 @@
{
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-ebbF7pug19PBgJQahUCTlvhcLS/LF8Nv1UIIG31LRmQ=";
# 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;
};
}
+19 -100
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.37",
"version": "0.1.32",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -16718,15 +16718,6 @@
"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",
@@ -25126,15 +25117,6 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -34860,16 +34842,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.37",
"version": "0.1.32",
"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.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34955,8 +34937,6 @@
},
"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": "*",
@@ -34976,8 +34956,6 @@
},
"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"
@@ -34985,11 +34963,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.37",
"version": "0.1.32",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35009,8 +34987,6 @@
},
"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"
@@ -35021,8 +34997,6 @@
},
"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"
@@ -35030,12 +35004,10 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.37",
"license": "AGPL-3.0-or-later",
"version": "0.1.32",
"dependencies": {
"@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.37",
"electron-log": "^5.4.3",
"@getpaseo/cli": "0.1.32",
"@getpaseo/server": "0.1.32",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35068,7 +35040,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.37",
"version": "0.1.32",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35269,7 +35241,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.37",
"version": "0.1.32",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35295,7 +35267,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.37",
"version": "0.1.32",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35311,14 +35283,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.37",
"version": "0.1.32",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/relay": "0.1.37",
"@isaacs/ttlcache": "^2.1.4",
"@getpaseo/highlight": "*",
"@getpaseo/relay": "0.1.32",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35361,19 +35332,8 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
"integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=12"
}
},
"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",
@@ -35395,8 +35355,6 @@
},
"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",
@@ -35411,8 +35369,6 @@
},
"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",
@@ -35459,8 +35415,6 @@
},
"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",
@@ -35472,8 +35426,6 @@
},
"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",
@@ -35488,8 +35440,6 @@
},
"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"
@@ -35500,8 +35450,6 @@
},
"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",
@@ -35520,8 +35468,6 @@
},
"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"
@@ -35532,8 +35478,6 @@
},
"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"
@@ -35541,8 +35485,6 @@
},
"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",
@@ -35558,8 +35500,6 @@
},
"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"
@@ -35567,8 +35507,6 @@
},
"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"
@@ -35576,8 +35514,6 @@
},
"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"
@@ -35588,8 +35524,6 @@
},
"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"
@@ -35600,8 +35534,6 @@
},
"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"
@@ -35640,8 +35572,6 @@
},
"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",
@@ -35662,8 +35592,6 @@
},
"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",
@@ -35677,8 +35605,6 @@
},
"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"
@@ -35692,8 +35618,6 @@
},
"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",
@@ -35706,8 +35630,6 @@
},
"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"
@@ -35715,14 +35637,13 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.37",
"version": "0.1.32",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
@@ -35742,8 +35663,6 @@
},
"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": {
+11 -19
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.32",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -44,18 +44,18 @@
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/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:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "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 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",
"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",
"release:finalize": "node scripts/finalize-current-release.mjs",
"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"
"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"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -68,7 +68,6 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -77,14 +76,7 @@
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
+6 -9
View File
@@ -448,15 +448,12 @@ 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}`,
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",
}
: {}),
// 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",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
-228
View File
@@ -1,228 +0,0 @@
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;
}
@@ -1,160 +0,0 @@
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(() => {});
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.37",
"version": "0.1.32",
"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.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -0,0 +1,36 @@
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;
}
+96 -136
View File
@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -25,14 +25,10 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -69,7 +65,6 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -81,18 +76,7 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -220,99 +204,41 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
}
return;
}
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setPhase("online");
setError(null);
setReady(true);
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
});
return () => {
cancelled = true;
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
@@ -473,30 +399,24 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [settingsLoading, settings.theme]);
}, [isLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [settingsLoading, resolvedTheme]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
@@ -599,38 +519,6 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -651,6 +539,57 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
@@ -667,7 +606,28 @@ export default function RootLayout() {
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<RootStack />
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
+75 -27
View File
@@ -1,22 +1,20 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHosts,
} from "@/runtime/host-runtime";
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 WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
@@ -35,31 +33,81 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
}
return firstOnlineServerId;
},
() => null,
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
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 (!storeReady) {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
}, [anyOnlineServerId, pathname, router, storeReady]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null;
}
+34 -2
View File
@@ -1,23 +1,55 @@
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]);
}, [daemons, preferences.serverId]);
useEffect(() => {
if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [router, targetServerId]);
}, [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>
);
}
if (!targetServerId) {
return <DraftAgentScreen />;
@@ -36,12 +36,10 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
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
// 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
return "#9ca3af"; // gray - unknown
}
@@ -537,10 +537,14 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
return models.map((model) => ({
id: model.id,
label: model.label,
}));
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
});
}
return opts;
}, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
@@ -582,7 +586,7 @@ export function AgentConfigRow({
title="Select model"
value={selectedModel}
options={modelSelectOptions}
placeholder={isModelLoading ? "Loading..." : "Select model"}
placeholder="Auto"
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
@@ -773,8 +777,10 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
const selectedLabel = selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
? undefined
: isLoading
@@ -784,20 +790,34 @@ export function ModelDropdown({
: undefined;
const options = useMemo(() => {
return models.map((model) => ({
id: model.id,
label: model.label,
description: model.description,
}));
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;
}, [models]);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
if (id === "") {
onClear();
} else {
onSelect(id);
}
},
[onSelect],
[onClear, 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,7 +59,6 @@ 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;
@@ -92,7 +91,6 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
agentId,
serverId,
isInputActive,
onSubmitMessage,
isSubmitLoading = false,
blurOnSubmit = false,
@@ -114,6 +112,8 @@ 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,14 +127,7 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
};
}),
);
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId),
@@ -267,6 +260,7 @@ export function AgentInputArea({
return updated;
});
}
const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, {
messageId: clientMessageId,
@@ -280,8 +274,41 @@ export function AgentInputArea({
onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]);
const isAgentRunning = agentState.status === "running";
const hasAgent = agentState.status !== null;
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 updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
@@ -323,7 +350,7 @@ export function AgentInputArea({
message,
imageAttachments,
forceSend,
isAgentRunning: agentState.status === "running",
isAgentRunning: agent?.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),
@@ -397,7 +424,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isInputActive) {
if (!isScreenFocused) {
return false;
}
@@ -433,7 +460,7 @@ export function AgentInputArea({
return false;
}
},
[isInputActive],
[isScreenFocused],
);
useKeyboardActionHandler({
@@ -445,9 +472,9 @@ export function AgentInputArea({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isInputActive,
enabled: isScreenFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isInputActive,
isActive: () => isScreenFocused,
handle: handleKeyboardAction,
});
@@ -456,7 +483,7 @@ export function AgentInputArea({
});
function handleCancelAgent() {
if (!isAgentRunning || isCancellingAgent) {
if (!agent || agent.status !== "running" || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
@@ -470,7 +497,7 @@ export function AgentInputArea({
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected || !hasAgent) {
if (!voice || !isConnected || !agent) {
return;
}
if (voice.isVoiceSwitching) {
@@ -487,7 +514,7 @@ export function AgentInputArea({
toast.error(message);
}
});
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
}, [agent, agentId, isConnected, serverId, toast, voice]);
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
@@ -579,7 +606,7 @@ export function AgentInputArea({
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent && hasAgent ? (
{!isVoiceModeForAgent && agent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
@@ -689,7 +716,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isInputActive={isInputActive}
isScreenFocused={isScreenFocused}
leftContent={leftContent}
rightContent={rightContent}
voiceServerId={serverId}
@@ -46,7 +46,6 @@ 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 values as unset", () => {
it("treats empty and default 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,50 +69,4 @@ 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,14 +1,11 @@
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";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -107,18 +104,21 @@ 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.blue[500];
return palette.amber[500];
case "dangerous":
return palette.red[500];
case "planning":
case "readonly":
return palette.purple[500];
default:
return palette.blue[500];
@@ -166,12 +166,8 @@ function ControlledStatusBar({
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Select model");
const displayThinking = findOptionLabel(
thinkingOptions,
selectedThinkingOptionId,
thinkingOptions?.[0]?.label ?? "Unknown",
);
: findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
@@ -607,30 +603,8 @@ function ControlledStatusBar({
);
}
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
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 agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
@@ -657,23 +631,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const models = modelsQuery.data ?? null;
const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
"default";
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeModelId,
runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
});
const modeOptions = useMemo<StatusOption[]>(() => {
return availableModes.map((mode) => ({
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}));
}, [availableModes]);
}, [agent?.availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
@@ -694,7 +668,9 @@ 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) => {
@@ -711,17 +687,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
@@ -732,23 +697,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
@@ -829,7 +777,7 @@ export function DraftAgentStatusBar({
label: definition.label,
}));
const modelOptions: StatusOption[] = [];
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
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) {
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized;
@@ -30,33 +30,25 @@ export function resolveAgentModelSelection(input: {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
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 preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
: fallbackModel;
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel =
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId =
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return {
selectedModel,
@@ -5,8 +5,8 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
useRef,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
@@ -39,9 +39,10 @@ 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 { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
@@ -82,9 +83,10 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: AgentScreenAgent;
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
isPaneFocused?: boolean;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
@@ -98,6 +100,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agent,
streamItems,
pendingPermissions,
isPaneFocused = false,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
onOpenWorkspaceFile,
@@ -208,6 +211,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -490,18 +497,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
{pendingPermissionItems.map((permission, index) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
client={client}
shortcutActive={isPaneFocused && index === 0}
/>
))}
</View>
) : null;
@@ -525,7 +532,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workingIndicator: workingIndicatorNode,
},
};
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
}, [
baseRenderModel,
client,
getGapBetween,
isPaneFocused,
pendingPermissionItems,
showWorkingIndicator,
]);
const listEmptyComponent = useMemo(() => {
if (
@@ -727,9 +741,11 @@ function WorkingIndicator() {
function PermissionRequestCard({
permission,
client,
shortcutActive,
}: {
permission: PendingPermission;
client: DaemonClient | null;
shortcutActive: boolean;
}) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -740,9 +756,6 @@ function PermissionRequestCard({
const description = request.description ?? "";
const planMarkdown = useMemo(() => {
if (!request) {
return undefined;
}
const planFromMetadata =
typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined;
if (planFromMetadata) {
@@ -868,6 +881,7 @@ function PermissionRequestCard({
resetPermissionMutation();
setRespondingAction(null);
}, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
respondToPermission({
@@ -881,12 +895,52 @@ function PermissionRequestCard({
[permission.agentId, permission.request.id, respondToPermission],
);
const handleDeny = useCallback(() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}, [handleResponse]);
const handleAccept = useCallback(() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}, [handleResponse]);
const handlePromptSelection = useCallback(
(action: { id: string; index?: number }): boolean => {
if (action.id !== "agent.prompt.select" || request.kind === "question" || isResponding) {
return false;
}
if (action.index === 1) {
handleDeny();
return true;
}
if (action.index === 2) {
handleAccept();
return true;
}
return false;
},
[handleAccept, handleDeny, isResponding, request.kind],
);
useKeyboardActionHandler({
handlerId: `agent-prompt-permission:${permission.key}`,
actions: ["agent.prompt.select"],
enabled: shortcutActive && request.kind !== "question" && !isResponding,
priority: 150,
handle: handlePromptSelection,
});
if (request.kind === "question") {
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
shortcutActive={shortcutActive}
/>
);
}
@@ -958,13 +1012,7 @@ function PermissionRequestCard({
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
onPress={handleDeny}
disabled={isResponding}
>
{respondingAction === "deny" ? (
@@ -989,10 +1037,7 @@ function PermissionRequestCard({
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
onPress={handleAccept}
disabled={isResponding}
>
{respondingAction === "accept" ? (
@@ -11,13 +11,6 @@ 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[]>;
@@ -73,9 +66,9 @@ export function CombinedModelSelector({
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Select model";
if (!models) return isLoading ? "Loading..." : "Auto";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
return model?.label ?? "Auto";
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (
@@ -10,7 +10,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -76,7 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -91,7 +90,6 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}
@@ -6,6 +6,7 @@ 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";
@@ -15,14 +16,13 @@ interface ScreenHeaderProps {
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -31,10 +31,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
// 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 collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -44,11 +42,7 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
<View
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
},
borderless && styles.borderless,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
]}
{...dragHandlers}
>
@@ -89,7 +83,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomWidth: 0,
},
}));
+5 -7
View File
@@ -520,7 +520,6 @@ function MobileSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
@@ -557,7 +556,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -582,7 +581,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -682,7 +681,7 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? (
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
@@ -702,7 +701,6 @@ function DesktopSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
)}
@@ -738,7 +736,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -763,7 +761,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
+20 -26
View File
@@ -66,8 +66,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isInputActive?: boolean;
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
isScreenFocused?: 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,
isInputActive = true,
isScreenFocused = true,
leftContent,
rightContent,
voiceServerId,
@@ -346,13 +346,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const handleDictationError = useCallback((error: Error) => {
console.error("[MessageInput] Dictation error:", error);
}, []);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
@@ -391,7 +387,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isInputActive },
autoStopWhenHidden: { isVisible: isScreenFocused },
enableDuration: true,
});
@@ -954,20 +950,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<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
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>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -1209,7 +1203,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
},
rightButtonGroup: {
flexDirection: "row",
+1 -2
View File
@@ -420,7 +420,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
},
}));
@@ -822,7 +821,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false}
selectable={false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
@@ -1,8 +1,9 @@
import { useState, useCallback } from "react";
import { useCallback, useState } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
interface QuestionOption {
@@ -17,6 +18,9 @@ interface Question {
multiSelect: boolean;
}
type QuestionSelections = Record<number, Set<number>>;
type QuestionOtherTexts = Record<number, string>;
function parseQuestions(input: unknown): Question[] | null {
if (
typeof input !== "object" ||
@@ -26,128 +30,334 @@ function parseQuestions(input: unknown): Question[] | null {
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
if (!Array.isArray(q.options)) return null;
if (typeof item !== "object" || item === null) {
return null;
}
const question = item as Record<string, unknown>;
if (typeof question.question !== "string" || typeof question.header !== "string") {
return null;
}
if (!Array.isArray(question.options)) {
return null;
}
const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null;
const o = opt as Record<string, unknown>;
if (typeof o.label !== "string") return null;
for (const option of question.options as unknown[]) {
if (typeof option !== "object" || option === null) {
return null;
}
const candidate = option as Record<string, unknown>;
if (typeof candidate.label !== "string") {
return null;
}
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
label: candidate.label,
description: typeof candidate.description === "string" ? candidate.description : undefined,
});
}
questions.push({
question: q.question,
header: q.header,
question: question.question,
header: question.header,
options,
multiSelect: q.multiSelect === true,
multiSelect: question.multiSelect === true,
});
}
return questions.length > 0 ? questions : null;
}
function cloneSelections(input: QuestionSelections): QuestionSelections {
const next: QuestionSelections = {};
for (const [key, value] of Object.entries(input)) {
next[Number(key)] = new Set(value);
}
return next;
}
function isQuestionAnswered(input: {
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
questionIndex: number;
}): boolean {
const selected = input.selections[input.questionIndex];
const otherText = input.otherTexts[input.questionIndex]?.trim();
return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0);
}
function findFirstUnansweredQuestionIndex(input: {
questions: readonly Question[];
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
}): number | null {
for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) {
if (
!isQuestionAnswered({
selections: input.selections,
otherTexts: input.otherTexts,
questionIndex,
})
) {
return questionIndex;
}
}
return null;
}
function areAllQuestionsAnswered(input: {
questions: readonly Question[];
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
}): boolean {
return input.questions.every((_, questionIndex) =>
isQuestionAnswered({
selections: input.selections,
otherTexts: input.otherTexts,
questionIndex,
}),
);
}
function buildQuestionAnswers(input: {
questions: readonly Question[];
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
}): Record<string, string> {
const answers: Record<string, string> = {};
for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) {
const question = input.questions[questionIndex];
if (!question) {
continue;
}
const selected = input.selections[questionIndex];
const otherText = input.otherTexts[questionIndex]?.trim();
if (otherText && otherText.length > 0) {
answers[question.header] = otherText;
continue;
}
if (!selected || selected.size === 0) {
continue;
}
const labels = Array.from(selected)
.map((optionIndex) => question.options[optionIndex]?.label)
.filter((label): label is string => typeof label === "string");
if (labels.length > 0) {
answers[question.header] = labels.join(", ");
}
}
return answers;
}
function selectQuestionOption(input: {
questions: readonly Question[];
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
questionIndex: number;
optionIndex: number;
}): {
selections: QuestionSelections;
otherTexts: QuestionOtherTexts;
} {
const question = input.questions[input.questionIndex];
if (!question || !question.options[input.optionIndex]) {
return {
selections: input.selections,
otherTexts: input.otherTexts,
};
}
const selections = cloneSelections(input.selections);
const otherTexts = { ...input.otherTexts };
const current = selections[input.questionIndex] ?? new Set<number>();
const next = new Set(current);
if (question.multiSelect) {
if (next.has(input.optionIndex)) {
next.delete(input.optionIndex);
} else {
next.add(input.optionIndex);
}
} else if (next.has(input.optionIndex)) {
next.clear();
} else {
next.clear();
next.add(input.optionIndex);
}
selections[input.questionIndex] = next;
delete otherTexts[input.questionIndex];
return { selections, otherTexts };
}
interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
shortcutActive: boolean;
}
const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
export function QuestionFormCard({
permission,
onRespond,
isResponding,
shortcutActive,
}: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [selections, setSelections] = useState<QuestionSelections>({});
const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
const submitResponses = useCallback(
(input: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => {
if (!questions) {
return;
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
if (text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
setRespondingAction("submit");
onRespond({
behavior: "allow",
updatedInput: {
...permission.request.input,
answers: buildQuestionAnswers({
questions,
selections: input.selections,
otherTexts: input.otherTexts,
}),
},
});
},
[onRespond, permission.request.input, questions],
);
const toggleOption = useCallback(
(input: { questionIndex: number; optionIndex: number }) => {
if (!questions) {
return;
}
const next = selectQuestionOption({
questions,
selections,
otherTexts,
questionIndex: input.questionIndex,
optionIndex: input.optionIndex,
});
setSelections(next.selections);
setOtherTexts(next.otherTexts);
},
[otherTexts, questions, selections],
);
const setOtherText = useCallback((input: { questionIndex: number; text: string }) => {
setOtherTexts((previous) => ({ ...previous, [input.questionIndex]: input.text }));
if (input.text.length > 0) {
setSelections((previous) => {
if (!previous[input.questionIndex] || previous[input.questionIndex]?.size === 0) {
return previous;
}
return { ...previous, [input.questionIndex]: new Set<number>() };
});
}
}, []);
if (!questions) {
return null;
}
const handleSubmit = useCallback(() => {
submitResponses({ selections, otherTexts });
}, [otherTexts, selections, submitResponses]);
const allAnswered = questions.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
});
function handleSubmit() {
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
const q = questions![i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
} else if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
answers[q.header] = labels.join(", ");
}
}
onRespond({
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
});
}
function handleDeny() {
const handleDeny = useCallback(() => {
setRespondingAction("dismiss");
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}, [onRespond]);
const handlePromptSelection = useCallback(
(action: { id: string; index?: number }): boolean => {
if (action.id !== "agent.prompt.select" || isResponding || !questions) {
return false;
}
const questionIndex = findFirstUnansweredQuestionIndex({
questions,
selections,
otherTexts,
});
if (questionIndex === null) {
return false;
}
const optionIndex = (action.index ?? 0) - 1;
const question = questions[questionIndex];
if (!question?.options[optionIndex]) {
return false;
}
const next = selectQuestionOption({
questions,
selections,
otherTexts,
questionIndex,
optionIndex,
});
setSelections(next.selections);
setOtherTexts(next.otherTexts);
const shouldAutoSubmit =
areAllQuestionsAnswered({
questions,
selections: next.selections,
otherTexts: next.otherTexts,
}) && questions.every((candidate) => candidate.multiSelect !== true);
if (shouldAutoSubmit) {
submitResponses(next);
}
return true;
},
[isResponding, otherTexts, questions, selections, submitResponses],
);
useKeyboardActionHandler({
handlerId: `agent-prompt-question:${permission.key}`,
actions: ["agent.prompt.select"],
enabled: shortcutActive && !isResponding && questions !== null,
priority: 150,
handle: handlePromptSelection,
});
if (!questions) {
return null;
}
const allAnswered = areAllQuestionsAnswered({
questions,
selections,
otherTexts,
});
return (
<View
style={[
@@ -158,24 +368,24 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
},
]}
>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
{questions.map((question, questionIndex) => {
const selected = selections[questionIndex] ?? new Set<number>();
const otherText = otherTexts[questionIndex] ?? "";
return (
<View key={qIndex} style={styles.questionBlock}>
<View key={questionIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
{question.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
{question.options.map((option, optionIndex) => {
const isSelected = selected.has(optionIndex);
return (
<Pressable
key={optIndex}
key={optionIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
@@ -183,22 +393,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
onPress={() =>
toggleOption({
questionIndex,
optionIndex,
})
}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
{option.label}
</Text>
{opt.description ? (
{option.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
{option.description}
</Text>
) : null}
</View>
@@ -222,12 +437,21 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
backgroundColor: theme.colors.surface2,
},
// @ts-expect-error - outlineStyle is web-only
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" },
IS_WEB && {
outlineStyle: "none",
outlineWidth: 0,
outlineColor: "transparent",
},
]}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
onChangeText={(text) =>
setOtherText({
questionIndex,
text,
})
}
editable={!isResponding}
/>
</View>
@@ -31,7 +31,6 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
@@ -74,7 +73,6 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -115,7 +113,6 @@ interface SidebarWorkspaceListProps {
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -395,15 +392,15 @@ function NewWorktreeButton({
}}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new workspace for ${displayName}`}
accessibilityLabel={`Create a new worktree for ${displayName}`}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<FolderPlus
size={15}
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -414,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
@@ -1634,7 +1631,6 @@ export function SidebarWorkspaceList({
isRefreshing = false,
onRefresh,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
@@ -1904,18 +1900,7 @@ export function SidebarWorkspaceList({
const content = (
<>
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={onAddProject}
>
Add project
</Button>
</View>
<Text style={styles.emptyText}>No projects yet</Text>
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1978,27 +1963,11 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
emptyContainer: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
emptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginTop: theme.spacing[8],
marginHorizontal: theme.spacing[2],
},
projectRow: {
minHeight: 36,
@@ -886,10 +886,7 @@ function SplitPaneView({
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
]}
>
<WorkspaceDesktopTabsRow
@@ -565,18 +565,6 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -598,10 +586,6 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}
+55 -133
View File
@@ -150,136 +150,70 @@ export function Autocomplete({
);
}
const selectedOption = options[selectedIndex];
return (
<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 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>
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
outerWrapper: {
gap: theme.spacing[1],
},
detailCard: {
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
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,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
scrollView: {
flexGrow: 0,
@@ -302,19 +236,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
marginRight: theme.spacing[1],
},
itemActive: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemMain: {
flex: 1,
minWidth: 0,
},
itemMainRow: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
@@ -334,11 +261,6 @@ 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],
+4 -49
View File
@@ -1,19 +1,11 @@
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import type { PropsWithChildren, ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
@@ -75,12 +67,6 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: {
color: theme.colors.palette.white,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
export function Button({
@@ -97,14 +83,11 @@ export function Button({
Omit<PressableProps, "style"> & {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
leftIcon?: ReactElement | null;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
@@ -117,47 +100,19 @@ export function Button({
: styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return (
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
@@ -167,7 +122,7 @@ export function Button({
style,
]}
>
{renderIcon()}
{leftIcon ? <View>{leftIcon}</View> : null}
<Text style={resolvedTextStyle}>{children}</Text>
</Pressable>
);
@@ -278,10 +278,7 @@ export function DropdownMenuContent({
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
// Avoid leaving an invisible full-screen Modal mounted on native when
// the exit animation callback does not fire.
setClosing(false);
setModalVisible(false);
setClosing(true);
}
}, [open, modalVisible]);
+3 -149
View File
@@ -1,17 +1,11 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useState } 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 {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -85,39 +79,6 @@ 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,
@@ -126,89 +87,6 @@ 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;
}
@@ -227,8 +105,6 @@ 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) => {
@@ -243,16 +119,6 @@ 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);
@@ -307,8 +173,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -322,9 +186,7 @@ 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}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<View style={styles.actions}>
{actions.map((action) => {
@@ -347,14 +209,6 @@ 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>
-4
View File
@@ -19,10 +19,6 @@ 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,10 +4,6 @@ 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);
@@ -35,59 +31,55 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
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(() => {
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) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const previousIsOpen = prevIsOpen.current;
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;
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
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, {
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,7 +49,6 @@ 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";
@@ -296,10 +295,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []);
useEffect(() => {
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
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;
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(
@@ -1,72 +0,0 @@
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());
});
});
@@ -1,29 +0,0 @@
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,10 +12,6 @@ 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);
@@ -42,53 +38,46 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
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(() => {
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) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
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;
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
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, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {
@@ -14,7 +14,6 @@ export function DesktopPermissionsSection() {
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions,
requestPermission,
sendTestNotification,
@@ -59,11 +58,6 @@ export function DesktopPermissionsSection() {
void sendTestNotification();
}}
/>
{testNotificationError ? (
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
{testNotificationError}
</Text>
) : null}
<DesktopPermissionRow
title="Microphone"
showBorder
@@ -86,9 +80,4 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
errorText: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
}));
@@ -27,12 +27,3 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}
-9
View File
@@ -37,13 +37,6 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -51,7 +44,6 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -81,7 +73,6 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {
@@ -14,7 +14,6 @@ export interface UseDesktopPermissionsReturn {
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean;
testNotificationError: string | null;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>;
@@ -113,25 +112,22 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktop, refreshPermissions],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsSendingTestNotification(true);
setTestNotificationError(null);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
console.warn("[Settings] Desktop test notification was not delivered");
}
} catch (error) {
setTestNotificationError("Failed to send notification.");
console.error("[Settings] Failed to send desktop test notification", error);
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
@@ -153,7 +149,6 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions,
requestPermission,
sendTestNotification,
@@ -10,15 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,36 +33,24 @@ export function UpdateBanner() {
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
@@ -80,7 +61,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && !isError && (
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -95,15 +76,6 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -137,21 +109,15 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
@@ -80,14 +80,20 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("prefers provider defaults on fresh drafts", () => {
it("keeps provider thinking preference when it is valid for the effective model", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
{
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "low",
},
},
},
codexModels,
{
serverId: false,
@@ -108,14 +114,20 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
expect(resolved.thinkingOptionId).toBe("low");
});
it("falls back to model default when saved thinking preference is invalid", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
{
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "medium",
},
},
},
codexModels,
{
serverId: false,
@@ -139,7 +151,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
it("normalizes legacy model id 'default' from initial values to auto", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
@@ -163,82 +175,21 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.model).toBe("");
});
it("normalizes legacy model id 'default' to the provider default model", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ 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");
});
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" },
],
},
];
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
claudeModels,
{
provider: "codex",
providerPreferences: {
codex: {
model: "default",
},
},
},
codexModels,
{
serverId: false,
provider: false,
@@ -249,7 +200,7 @@ describe("useAgentFormState", () => {
},
{
serverId: null,
provider: "claude",
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
@@ -258,8 +209,7 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("default");
expect(resolved.thinkingOptionId).toBe("");
expect(resolved.model).toBe("");
});
it("resolves provider only from allowed provider map", () => {
+107 -139
View File
@@ -11,12 +11,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
// Explicit overrides from URL params or "New Agent" button
export interface FormInitialValues {
@@ -107,7 +102,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) {
if (!normalized || normalized.toLowerCase() === "default") {
return "";
}
return normalized;
@@ -122,10 +117,6 @@ 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,
@@ -143,31 +134,9 @@ 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) > provider defaults > lightweight app prefs > fallback
* Priority: explicit (URL params) > preferences > provider defaults > fallback
*
* Only resolves fields that haven't been user-modified.
*/
@@ -226,22 +195,25 @@ 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 = defaultModelId;
result.model = "";
}
} 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 = defaultModelId;
result.model = "";
}
} else if (defaultModelId) {
result.model = defaultModelId;
} else {
result.model = "";
}
@@ -252,17 +224,13 @@ 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 (preferredThinking.length > 0) {
result.thinkingOptionId = preferredThinking;
} else if (preferredThinkingOptionId.length > 0) {
result.thinkingOptionId = preferredThinkingOptionId;
} else {
result.thinkingOptionId = "";
}
@@ -270,17 +238,27 @@ function resolveFormState(
// Validate thinking option once model metadata is available.
if (availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: result.model,
requestedThinkingOptionId: result.thinkingOptionId,
});
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;
}
}
}
// 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
}
@@ -289,6 +267,8 @@ 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)
}
@@ -333,6 +313,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
preferences,
isLoading: isPreferencesLoading,
updatePreferences,
updateProviderPreferences,
} = useFormPreferences();
const daemons = useHosts();
@@ -556,119 +537,107 @@ 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) => {
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,
});
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 providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
setFormState((prev) => ({
...prev,
provider,
modeId: nextModeId,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: normalizeSelectedModelId(providerPrefs?.model),
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
},
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: "",
});
const providerPrefs = preferences?.providerPreferences?.[provider];
setFormState((prev) => ({
...prev,
provider,
model: nextModelId,
modeId: providerDef?.defaultModeId ?? "",
thinkingOptionId: nextThinkingOptionId,
model: modelId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
},
[allProviderModels, providerDefinitionMap, updatePreferences],
[
preferences?.providerPreferences,
providerDefinitionMap,
updatePreferences,
updateProviderPreferences,
],
);
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);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: nextModelId,
requestedThinkingOptionId: userModified.thinkingOptionId
? formStateRef.current.thinkingOptionId
: "",
});
setFormState((prev) => ({
...prev,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
},
[availableModels, userModified.thinkingOptionId],
[formState.provider, updateProviderPreferences],
);
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) => {
@@ -679,8 +648,9 @@ 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) => {
@@ -692,39 +662,39 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
const providerPreferenceUpdates: {
mode: string;
model: string;
thinkingOptionId?: string;
} = {
mode: formState.modeId,
model: formState.model,
};
if (userModified.thinkingOptionId) {
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
}
await updatePreferences(nextPreferences);
await updatePreferences({
workingDir: formState.workingDir,
provider: formState.provider,
serverId: formState.serverId ?? undefined,
});
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
}, [
availableModels,
formState.model,
formState.modeId,
formState.model,
formState.provider,
formState.serverId,
formState.thinkingOptionId,
preferences?.providerPreferences,
formState.workingDir,
userModified.thinkingOptionId,
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 =
@@ -741,7 +711,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setProviderFromUser,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: resolvedModelId,
selectedModel: formState.model,
setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser,
@@ -767,7 +737,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
formState.serverId,
formState.provider,
formState.modeId,
resolvedModelId,
formState.model,
formState.thinkingOptionId,
formState.workingDir,
setSelectedServerId,
@@ -801,7 +771,5 @@ export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = {
combineInitialValues,
resolveDefaultModel,
resolveFormState,
resolveThinkingOptionId,
};
@@ -1,17 +1,5 @@
import { useRef } from "react";
export interface AgentScreenAgent {
serverId: string;
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
projectPlacement?: {
checkout?: {
cwd?: string;
isGit?: boolean;
};
} | null;
}
import type { Agent } from "@/contexts/session-context";
export type AgentScreenMissingState =
| { kind: "idle" }
@@ -20,8 +8,8 @@ export type AgentScreenMissingState =
| { kind: "error"; message: string };
export interface AgentScreenMachineInput {
agent: AgentScreenAgent | null;
placeholderAgent: AgentScreenAgent | null;
agent: Agent | null;
placeholderAgent: Agent | null;
missingAgentState: AgentScreenMissingState;
isConnected: boolean;
isArchivingCurrentAgent: boolean;
@@ -43,7 +31,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: AgentScreenAgent | null;
lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
@@ -82,7 +70,7 @@ export type AgentScreenViewState =
}
| {
tag: "ready";
agent: AgentScreenAgent;
agent: Agent;
source: "authoritative" | "optimistic" | "stale";
sync: AgentScreenReadySyncState;
isArchiving: boolean;
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
expect(
mergeProviderPreferences({
preferences: {},
provider: "claude",
updates: { model: "claude-opus-4-6" },
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-opus-4-6",
},
},
});
});
it("merges thinking preferences by model without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
},
},
},
},
provider: "claude",
updates: {
thinkingByModel: {
"claude-opus-4-6": "high",
},
},
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
"claude-opus-4-6": "high",
},
},
},
});
});
});
+38 -31
View File
@@ -10,11 +10,13 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
thinkingOptionId: z.string().optional(),
});
const formPreferencesSchema = z.object({
workingDir: z.string().optional(),
provider: z.string().optional(),
serverId: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
});
@@ -33,37 +35,12 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
},
},
};
updateProviderPreferences: (
provider: AgentProvider,
updates: Partial<ProviderPreferences>,
) => Promise<void>;
}
export function useFormPreferences(): UseFormPreferencesReturn {
@@ -77,6 +54,13 @@ 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 =
@@ -89,9 +73,32 @@ 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,
};
}
@@ -161,6 +161,15 @@ export function useKeyboardShortcuts({
switch (input.action) {
case "agent.new":
return openProjectPicker();
case "agent.prompt.select":
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
return false;
}
return keyboardActionDispatcher.dispatch({
id: "agent.prompt.select",
scope: "workspace",
index: input.payload.index,
});
case "workspace.tab.new":
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.new",
@@ -30,16 +30,6 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
theme: "dark",
manageBuiltInDaemon: true,
};
+1
View File
@@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind =
export type KeyboardActionId =
| "agent.new"
| "agent.prompt.select"
| "workspace.tab.new"
| "workspace.tab.close.current"
| "workspace.tab.navigate.index"
@@ -89,43 +89,6 @@ 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 = {
@@ -1,6 +1,7 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type KeyboardActionId =
| "agent.prompt.select"
| "message-input.focus"
| "message-input.dictation-toggle"
| "message-input.dictation-cancel"
@@ -26,6 +27,7 @@ export type KeyboardActionId =
| "worktree.archive";
export type KeyboardActionDefinition =
| { id: "agent.prompt.select"; scope: KeyboardActionScope; index: number }
| { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
@@ -261,9 +261,15 @@ describe("keyboard-shortcuts", () => {
action: "sidebar.toggle.left",
},
{
name: "keeps Mod+. as sidebar toggle fallback",
name: "matches Mod+. to toggle both sidebars on non-mac",
event: { key: ".", code: "Period", ctrlKey: true },
context: { isMac: false },
action: "sidebar.toggle.both",
},
{
name: "matches Ctrl+B sidebar toggle on non-mac",
event: { key: "b", code: "KeyB", ctrlKey: true },
context: { isMac: false },
action: "sidebar.toggle.left",
},
{
@@ -289,6 +295,13 @@ describe("keyboard-shortcuts", () => {
preventDefault: false,
stopPropagation: false,
},
{
name: "routes number keys to prompt selection outside editable scopes",
event: { key: "2", code: "Digit2" },
context: { focusScope: "other" },
action: "agent.prompt.select",
payload: { index: 2 },
},
];
it.each(matchingCases)("$name", ({
@@ -344,11 +357,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "k", code: "KeyK", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
},
{
name: "does not bind Ctrl+B on non-mac",
event: { key: "b", code: "KeyB", ctrlKey: true },
context: { isMac: false },
},
{
name: "does not route message-input actions when terminal is focused",
event: { key: "d", code: "KeyD", metaKey: true },
@@ -364,6 +372,11 @@ describe("keyboard-shortcuts", () => {
event: { key: " ", code: "Space" },
context: { focusScope: "message-input" },
},
{
name: "does not route prompt selection while typing in the message input",
event: { key: "1", code: "Digit1" },
context: { focusScope: "message-input" },
},
];
it.each(nonMatchingCases)("$name", ({ event, context }) => {
@@ -467,6 +480,9 @@ describe("keyboard-shortcut help sections", () => {
"workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
"agent-prompt-select-1": ["1"],
"agent-prompt-select-2": ["2"],
"agent-prompt-select-3": ["3"],
},
},
{
@@ -477,10 +493,11 @@ describe("keyboard-shortcut help sections", () => {
},
},
{
name: "uses mod+period as non-mac left sidebar shortcut",
name: "uses mod+b and mod+period for non-mac sidebar shortcuts",
context: { isMac: false, isDesktop: false },
expectedKeys: {
"toggle-left-sidebar": ["mod", "."],
"toggle-left-sidebar": ["mod", "B"],
"toggle-both-sidebars": ["mod", "."],
},
},
];
@@ -814,6 +814,48 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
keys: ["Esc"],
},
},
{
id: "agent-prompt-select-1",
action: "agent.prompt.select",
combo: "1",
repeat: false,
when: { focusScope: "other", commandCenter: false },
payload: { type: "index" },
help: {
id: "agent-prompt-select-1",
section: "agent-input",
label: "Select prompt option 1",
keys: ["1"],
},
},
{
id: "agent-prompt-select-2",
action: "agent.prompt.select",
combo: "2",
repeat: false,
when: { focusScope: "other", commandCenter: false },
payload: { type: "index" },
help: {
id: "agent-prompt-select-2",
section: "agent-input",
label: "Select prompt option 2",
keys: ["2"],
},
},
{
id: "agent-prompt-select-3",
action: "agent.prompt.select",
combo: "3",
repeat: false,
when: { focusScope: "other", commandCenter: false },
payload: { type: "index" },
help: {
id: "agent-prompt-select-3",
section: "agent-input",
label: "Select prompt option 3",
keys: ["3"],
},
},
{
id: "message-input-send-enter",
action: "message-input.action",
+86 -62
View File
@@ -2,8 +2,6 @@ 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";
@@ -18,7 +16,6 @@ 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";
@@ -81,21 +78,11 @@ function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
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 agent = useSessionStore(
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null,
);
const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const provider = agent?.provider ?? "codex";
const label = resolveWorkspaceAgentTabLabel(agent?.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
return {
@@ -103,12 +90,12 @@ function useAgentPanelDescriptor(
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: descriptorState.status
statusBucket: agent
? deriveSidebarStateBucket({
status: descriptorState.status,
pendingPermissionCount: descriptorState.pendingPermissionCount,
requiresAttention: descriptorState.requiresAttention,
attentionReason: descriptorState.attentionReason,
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
})
: null,
};
@@ -256,25 +243,8 @@ function AgentPanelBody({
addImagesRef.current = addImages;
}, []);
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 agent = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined,
);
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
@@ -348,14 +318,53 @@ function AgentPanelBody({
agentId,
client,
isConnected,
requiresAttention: agentState.requiresAttention,
attentionReason: agentState.attentionReason,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.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",
});
@@ -480,34 +489,49 @@ function AgentPanelBody({
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
const authoritativeStatus = agentState.status;
const authoritativeStatus = agent?.status;
const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
const showPendingCreateSubmitLoading =
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
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(() => {
const placeholderAgent: Agent | 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: ".",
projectPlacement: null,
model: null,
labels: {},
};
}, [agentId, serverId, shouldUseOptimisticStream]);
@@ -619,7 +643,7 @@ function AgentPanelBody({
if (!agentId) {
return;
}
if (agentState.id || shouldUseOptimisticStream) {
if (agent || shouldUseOptimisticStream) {
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
@@ -694,7 +718,7 @@ function AgentPanelBody({
setMissingAgentState({ kind: "error", message });
});
}, [
agentState.id,
agent,
agentId,
client,
ensureAgentIsInitialized,
@@ -773,6 +797,7 @@ function AgentPanelBody({
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
isPaneFocused={isPaneFocused}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -780,11 +805,10 @@ function AgentPanelBody({
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
<AgentInputArea
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
images={agentInputDraft.images}
@@ -809,7 +833,7 @@ function AgentPanelBody({
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
) : agentId && agentState.archivedAt ? (
) : agentId && agent?.archivedAt ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null}
+2 -10
View File
@@ -17,15 +17,8 @@ function useDraftPanelDescriptor() {
}
function DraftPanel() {
const {
serverId,
workspaceId,
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
@@ -34,7 +27,6 @@ function DraftPanel() {
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
isPaneFocused={isPaneFocused}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
+17 -168
View File
@@ -15,10 +15,7 @@ import {
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -36,10 +33,6 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRuntimeBootstrapResult =
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
| { ok: false; error: string };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1076,11 +1069,9 @@ export class HostRuntimeController {
}
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1145,7 +1136,7 @@ export class HostRuntimeStore {
}
}
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
async bootstrap(): Promise<void> {
if (this.bootstrapAttempted) {
return;
}
@@ -1161,48 +1152,30 @@ export class HostRuntimeStore {
}
if (shouldUseDesktopDaemon()) {
if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
await this.bootstrapDesktop();
} else {
await this.bootstrapLocalhost();
}
}
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
private async bootstrapDesktop(): Promise<void> {
try {
const daemon = await startDesktopDaemon();
const listenAddress = daemon.listen.trim();
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {
ok: false,
error: "Desktop daemon did not return a listen address.",
};
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
if (!serverId) {
return {
ok: false,
error: "Desktop daemon did not return a server id.",
};
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
return {
ok: true,
listenAddress,
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
hostname: daemon.hostname,
};
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
return {
ok: false,
error: toErrorMessage(error),
};
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
}
@@ -1308,36 +1281,6 @@ export class HostRuntimeStore {
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
listenAddress: string;
serverId: string;
hostname: string | null;
timeoutMs?: number;
}): Promise<HostProfile> {
const normalizedListenAddress = input.listenAddress.trim();
const serverId = input.serverId.trim();
const connection = connectionFromListen(normalizedListenAddress);
if (!connection) {
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
}
if (!serverId) {
throw new Error("Desktop daemon did not return a server id.");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.hostname ?? undefined,
connection,
});
await this.waitForConnectionOnline({
serverId,
connectionId: connection.id,
timeoutMs: input.timeoutMs,
});
return profile;
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
@@ -1499,100 +1442,6 @@ export class HostRuntimeStore {
}
}
private waitForConnectionOnline(input: {
serverId: string;
connectionId: string;
timeoutMs?: number;
}): Promise<void> {
const { serverId, connectionId } = input;
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (unsubscribe: (() => void) | null): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
unsubscribe?.();
};
const settle = (
unsubscribe: (() => void) | null,
outcome: { ok: true } | { ok: false; error: Error },
): void => {
if (settled) {
return;
}
settled = true;
cleanup(unsubscribe);
if (outcome.ok) {
resolve();
} else {
reject(outcome.error);
}
};
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
const snapshot = this.getSnapshot(serverId);
if (!snapshot) {
return {
ok: false,
error: new Error(`Unknown host runtime for serverId ${serverId}`),
};
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "online"
) {
return { ok: true };
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "error"
) {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
};
}
return null;
};
const unsubscribe = this.subscribe(serverId, () => {
const outcome = readSnapshot();
if (outcome) {
settle(unsubscribe, outcome);
}
});
timeoutHandle = setTimeout(() => {
settle(unsubscribe, {
ok: false,
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
});
}, timeoutMs);
const initialOutcome = readSnapshot();
if (initialOutcome) {
settle(unsubscribe, initialOutcome);
return;
}
void this.runProbeCycleNow(serverId).catch((error) => {
settle(unsubscribe, {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
});
}
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
@@ -744,20 +744,6 @@ 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
@@ -770,18 +756,18 @@ function DraftAgentScreenContent({
provider: selectedProvider,
cwd,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}),
};
}, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
selectedWorktreePath,
workingDir,
]);
@@ -815,12 +801,6 @@ 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";
}
@@ -852,8 +832,8 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
".";
const provider = selectedProvider;
const model = effectiveDraftModelId || null;
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
@@ -889,14 +869,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 } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
};
const effectiveBaseBranch = baseBranch.trim();
@@ -1219,7 +1199,6 @@ function DraftAgentScreenContent({
<AgentInputArea
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
@@ -1,22 +1,26 @@
import { useEffect } from "react";
import { View, Text } from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
@@ -27,24 +31,22 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container} {...dragHandlers}>
<MenuHeader borderless />
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
Add a project
</Button>
</View>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
<Pressable
style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]}
onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
</View>
</View>
);
@@ -56,32 +58,43 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
gap: 0,
gap: theme.spacing[6],
padding: theme.spacing[6],
},
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
},
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
openButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
openButtonHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
openButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
+8 -36
View File
@@ -1,14 +1,10 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
@@ -48,26 +44,13 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
{sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
>
Back
</Button>
</View>
) : (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
)}
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
</View>
);
}
@@ -77,15 +60,4 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
}));
@@ -896,6 +896,12 @@ 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],
);
@@ -1,318 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
type StartupSplashScreenProps = {
bootstrapState?: {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
};
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
width: "100%",
gap: theme.spacing[6],
},
errorHeader: {
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
status: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
errorMessage: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
logsContainer: {
height: 200,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
logsScroll: {
flexGrow: 0,
},
logsContent: {
padding: theme.spacing[4],
},
logsText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[3],
flexWrap: "wrap",
},
}));
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
export function StartupSplashScreen() {
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
setDaemonLogs(null);
setLogsError(null);
setIsLoadingLogs(false);
return;
}
let isCancelled = false;
setIsLoadingLogs(true);
setLogsError(null);
void getDesktopDaemonLogs()
.then((logs) => {
if (isCancelled) {
return;
}
setDaemonLogs(logs);
})
.catch((error) => {
if (isCancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setDaemonLogs(null);
setLogsError(`Unable to load daemon logs: ${message}`);
})
.finally(() => {
if (!isCancelled) {
setIsLoadingLogs(false);
}
});
return () => {
isCancelled = true;
};
}, [isError]);
const progressSteps =
phase === "starting-daemon"
? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }]
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connected to local server", status: "complete" as const },
];
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
}
if (daemonLogs?.contents) {
return daemonLogs.contents;
}
if (logsError) {
return logsError;
}
return "No daemon logs available.";
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
const handleCopyLogs = () => {
const payload = daemonLogs?.logPath
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
: logsText;
void Clipboard.setStringAsync(payload);
};
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up…</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
</View>
);
}
return (
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text>
</View>
);
}
@@ -12,7 +12,6 @@ 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,
@@ -34,7 +33,6 @@ type WorkspaceDraftAgentTabProps = {
workspaceId: string;
tabId: string;
draftId: string;
isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string }) => void;
};
@@ -44,7 +42,6 @@ export function WorkspaceDraftAgentTab({
workspaceId,
tabId,
draftId,
isPaneFocused,
onCreated,
onOpenWorkspaceFile,
}: WorkspaceDraftAgentTabProps) {
@@ -95,22 +92,6 @@ 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,
@@ -127,12 +108,6 @@ 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";
}
@@ -147,8 +122,8 @@ export function WorkspaceDraftAgentTab({
},
buildDraftAgent: (attempt) => {
const now = attempt.timestamp;
const model = effectiveDraftModelId || null;
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
serverId,
@@ -178,14 +153,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 } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
};
const imagesData = await encodeImages(images);
@@ -211,17 +186,17 @@ export function WorkspaceDraftAgentTab({
provider: selectedProvider,
cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}),
};
}, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
workspaceId,
]);
@@ -268,7 +243,6 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea
agentId={tabId}
serverId={serverId}
isInputActive={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
@@ -277,7 +251,7 @@ export function WorkspaceDraftAgentTab({
images={draftInput.images}
onChangeImages={draftInput.setImages}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
statusControls={{
@@ -1,31 +0,0 @@
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);
});
});
@@ -1,6 +0,0 @@
export function shouldAutoFocusWorkspaceDraftComposer(input: {
isPaneFocused: boolean;
isSubmitting: boolean;
}): boolean {
return input.isPaneFocused && !input.isSubmitting;
}
@@ -37,36 +37,4 @@ 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,9 +1,6 @@
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.
*
@@ -18,7 +15,6 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
body: {
...webSelectableTextStyle,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
@@ -28,7 +24,6 @@ export function createMarkdownStyles(theme: Theme) {
},
text: {
...webSelectableTextStyle,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
@@ -52,7 +47,6 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
heading1: {
...webSelectableTextStyle,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
@@ -65,7 +59,6 @@ export function createMarkdownStyles(theme: Theme) {
},
heading2: {
...webSelectableTextStyle,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
@@ -78,7 +71,6 @@ export function createMarkdownStyles(theme: Theme) {
},
heading3: {
...webSelectableTextStyle,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -88,7 +80,6 @@ export function createMarkdownStyles(theme: Theme) {
},
heading4: {
...webSelectableTextStyle,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -98,7 +89,6 @@ export function createMarkdownStyles(theme: Theme) {
},
heading5: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -108,7 +98,6 @@ export function createMarkdownStyles(theme: Theme) {
},
heading6: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted,
@@ -124,23 +113,19 @@ 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,
@@ -149,7 +134,6 @@ export function createMarkdownStyles(theme: Theme) {
},
blocklink: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
@@ -162,7 +146,6 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
code_inline: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1],
@@ -174,7 +157,6 @@ export function createMarkdownStyles(theme: Theme) {
},
code_block: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
@@ -185,7 +167,6 @@ export function createMarkdownStyles(theme: Theme) {
},
fence: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
@@ -219,7 +200,6 @@ export function createMarkdownStyles(theme: Theme) {
tbody: {},
th: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderBottomWidth: 1,
borderRightWidth: 1,
@@ -238,7 +218,6 @@ export function createMarkdownStyles(theme: Theme) {
},
td: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderRightWidth: 1,
borderColor: theme.colors.border,
@@ -281,7 +260,6 @@ export function createMarkdownStyles(theme: Theme) {
},
bullet_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
@@ -289,7 +267,6 @@ export function createMarkdownStyles(theme: Theme) {
},
ordered_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
@@ -8,7 +8,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -169,12 +168,7 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
+6 -20
View File
@@ -2,11 +2,8 @@ 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";
@@ -123,11 +120,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktop()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -178,23 +175,12 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu
};
}, []);
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',
};
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
});
it("preserves 'default' as a valid model id", () => {
it("treats legacy 'default' model ids as unset", () => {
const agent = {
model: "default",
runtimeInfo: { model: "default" },
} as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBe("default");
expect(extractAgentModel(agent)).toBeNull();
});
});
@@ -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) {
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
return normalized;
}
}
if (typeof fallbackModel === "string") {
const normalized = fallbackModel.trim();
if (normalized.length > 0) {
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
return normalized;
}
}
@@ -1,44 +0,0 @@
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,
});
});
});
@@ -1,41 +0,0 @@
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,
};
}
-99
View File
@@ -1,99 +0,0 @@
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);
});
});
+3 -1
View File
@@ -40,7 +40,9 @@ export function focusWithRetries({
});
};
tick();
requestAnimationFrame(() => {
requestAnimationFrame(tick);
});
return () => {
cancelled = true;
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.37",
"version": "0.1.32",
"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.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
-17
View File
@@ -2,11 +2,8 @@ import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
@@ -20,7 +17,6 @@ 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";
@@ -97,10 +93,6 @@ 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());
@@ -140,15 +132,6 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());
+1 -8
View File
@@ -1,4 +1,4 @@
import { Command } from "commander";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
@@ -24,13 +24,6 @@ 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;
+4 -2
View File
@@ -1,6 +1,5 @@
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
@@ -70,9 +69,12 @@ 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;
return isSameOrDescendantPath(options.cwd!, a.cwd);
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+6 -2
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { runModeCommand } from "./mode.js";
import { addArchiveOptions, runArchiveCommand } from "./archive.js";
import { runArchiveCommand } from "./archive.js";
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
import { addLsOptions, runLsCommand } from "./ls.js";
import { addRunOptions, runRunCommand } from "./run.js";
@@ -69,7 +69,11 @@ export function createAgentCommand(): Command {
).action(withOutput(runModeCommand));
addJsonAndDaemonHostOptions(
addArchiveOptions(agent.command("archive")),
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)"),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(
+5 -2
View File
@@ -3,7 +3,6 @@ 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
@@ -194,7 +193,11 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
}
// Apply label filtering only when explicitly requested.
+2 -2
View File
@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
};
export interface AgentSendOptions extends CommandOptions {
wait?: boolean;
noWait?: 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.wait === false) {
if (options.noWait) {
await client.close();
return {

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