mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 | ||
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 | ||
|
|
e5fd61f131 | ||
|
|
487af3c822 | ||
|
|
a7439c8e56 | ||
|
|
930660074d | ||
|
|
fd030e8673 | ||
|
|
bb73147efd | ||
|
|
04b21f089c | ||
|
|
fb42c8eea2 | ||
|
|
971f74e2ab | ||
|
|
4318aa2bb1 | ||
|
|
a1761363fa | ||
|
|
096417d5ac | ||
|
|
35446e01a2 | ||
|
|
6c3559e90f | ||
|
|
409ab466ca | ||
|
|
efb8c8f229 | ||
|
|
b1d663557b | ||
|
|
a460c5e9b9 | ||
|
|
831e3289e7 | ||
|
|
094864d779 | ||
|
|
147482d6ed | ||
|
|
921ddf47a8 | ||
|
|
f287c7f972 | ||
|
|
9eb1f93296 | ||
|
|
95d56ddf15 | ||
|
|
62b1e94257 | ||
|
|
01bab92fed | ||
|
|
dae677edeb | ||
|
|
03a5323755 | ||
|
|
2e9f935dbc | ||
|
|
93845db70a | ||
|
|
d5085294df | ||
|
|
a3aed9b8b6 | ||
|
|
7ca428677c | ||
|
|
9ad3e7661b | ||
|
|
05a4e8f5a3 | ||
|
|
1b0fe4aa47 | ||
|
|
29876acae5 | ||
|
|
657b3e1ccd | ||
|
|
a2ab0af3f1 | ||
|
|
bb9e181c20 | ||
|
|
7bc1f04e27 | ||
|
|
394b9cac13 | ||
|
|
be41911083 | ||
|
|
8f11902cb7 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [boudra]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -26,6 +26,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/app
|
run: npm run typecheck --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ on:
|
|||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'patches/**'
|
- 'patches/**'
|
||||||
- '.github/workflows/deploy-website.yml'
|
- '.github/workflows/deploy-website.yml'
|
||||||
|
release:
|
||||||
|
types: [published, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ jobs:
|
|||||||
release_type="release"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
release_type="release"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Fix Nix hash
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fix-nix-hash:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Fix lockfile and update hash
|
||||||
|
run: ./scripts/update-nix.sh
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
run: |
|
||||||
|
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add package-lock.json nix/package.nix
|
||||||
|
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Nix Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Build Nix package
|
||||||
|
run: nix build .#default -o result
|
||||||
|
|
||||||
|
- name: Verify lockfile is complete
|
||||||
|
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||||
|
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||||
|
run: |
|
||||||
|
node scripts/fix-lockfile.mjs package-lock.json
|
||||||
|
git diff --exit-code package-lock.json || {
|
||||||
|
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||||
|
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||||
|
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check npmDepsHash is up to date
|
||||||
|
run: ./scripts/update-nix.sh --check
|
||||||
@@ -36,6 +36,9 @@ jobs:
|
|||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Build relay dependency
|
- name: Build relay dependency
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,82 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.38 - 2026-03-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
||||||
|
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
||||||
|
|
||||||
|
## 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
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
npm run typecheck # Always run after changes
|
||||||
|
npm run db:query # Show DB table row counts
|
||||||
|
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||||
@@ -46,9 +48,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
|
|
||||||
## Orchestrator mode
|
## Debugging
|
||||||
|
|
||||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
|
||||||
- Launch agents in the most permissive mode
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
- Set cwd to the repository root
|
|
||||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
|
||||||
|
|||||||
@@ -4,40 +4,100 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">One interface for all your coding agents.</p>
|
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
|
|
||||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||||
|
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
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.
|
||||||
|
|
||||||
### Headless / server mode
|
### Prerequisites
|
||||||
|
|
||||||
To run the daemon on a remote or headless machine:
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Then connect from the desktop app, mobile app, or CLI.
|
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Everything you can do in the app, you can do from the terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
|
paseo ls # list running agents
|
||||||
|
paseo attach abc123 # stream live output
|
||||||
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
|
# run on a remote daemon
|
||||||
|
paseo --host workstation.local:6767 run "run the full test suite"
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
|
|
||||||
|
## Orchestration skills (Unstable)
|
||||||
|
|
||||||
|
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add getpaseo/paseo
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use them in any agent conversation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||||
|
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||||
|
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||||
|
|
||||||
|
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||||
|
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||||
|
|
||||||
|
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||||
|
# Very opinionated and expects both Codex and Claude to work.
|
||||||
|
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Quick monorepo package map:
|
Quick monorepo package map:
|
||||||
@@ -57,8 +117,12 @@ npm run dev
|
|||||||
# run individual surfaces
|
# run individual surfaces
|
||||||
npm run dev:server
|
npm run dev:server
|
||||||
npm run dev:app
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
npm run dev:website
|
npm run dev:website
|
||||||
|
|
||||||
|
# build the daemon
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
# repo-wide checks
|
# repo-wide checks
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
|||||||
|
|
||||||
## Reporting vulnerabilities
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ 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
|
||||||
|
|
||||||
### `packages/server` — The daemon
|
### `packages/server` — The daemon
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
|||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||||
|
|
||||||
|
### Database queries
|
||||||
|
|
||||||
|
Run arbitrary SQL against the SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show table row counts
|
||||||
|
npm run db:query
|
||||||
|
|
||||||
|
# Run any SQL
|
||||||
|
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||||
|
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||||
|
|
||||||
|
# Point at a specific DB directory
|
||||||
|
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||||
|
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||||
|
|
||||||
## Build sync gotchas
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### Relay → Daemon
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# File Icons
|
||||||
|
|
||||||
|
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||||
|
|
||||||
|
Icons are inlined as SVG strings in:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/app/src/components/material-file-icons.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is auto-generated. Do not edit it by hand.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||||
|
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||||
|
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||||
|
|
||||||
|
## Adding a new icon
|
||||||
|
|
||||||
|
1. Find the icon name in the material-icon-theme manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "
|
||||||
|
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
|
||||||
|
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
|
||||||
|
console.log('languageIds:', m.languageIds['YOUR_LANG']);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify the SVG exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add two things to `material-file-icons.ts`:
|
||||||
|
|
||||||
|
- The SVG string in `SVG_ICONS`:
|
||||||
|
```ts
|
||||||
|
"icon_name": `<svg ...>...</svg>`,
|
||||||
|
```
|
||||||
|
|
||||||
|
- The extension mapping in `EXTENSION_TO_ICON`:
|
||||||
|
```ts
|
||||||
|
"ext": "icon_name",
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run `npm run typecheck` to verify.
|
||||||
|
|
||||||
|
## Currently included icons
|
||||||
|
|
||||||
|
53 unique icons covering these extensions:
|
||||||
|
|
||||||
|
| Extension(s) | Icon |
|
||||||
|
|---|---|
|
||||||
|
| `ts` | typescript |
|
||||||
|
| `tsx` | react_ts |
|
||||||
|
| `js` | javascript |
|
||||||
|
| `jsx` | react |
|
||||||
|
| `py` | python |
|
||||||
|
| `go` | go |
|
||||||
|
| `rs` | rust |
|
||||||
|
| `rb` | ruby |
|
||||||
|
| `java` | java |
|
||||||
|
| `kt` | kotlin |
|
||||||
|
| `c` | c |
|
||||||
|
| `cpp` | cpp |
|
||||||
|
| `h` | h |
|
||||||
|
| `hpp` | hpp |
|
||||||
|
| `cs` | csharp |
|
||||||
|
| `swift` | swift |
|
||||||
|
| `dart` | dart |
|
||||||
|
| `ex`, `exs` | elixir |
|
||||||
|
| `erl` | erlang |
|
||||||
|
| `hs` | haskell |
|
||||||
|
| `clj` | clojure |
|
||||||
|
| `scala` | scala |
|
||||||
|
| `ml` | ocaml |
|
||||||
|
| `r` | r |
|
||||||
|
| `lua` | lua |
|
||||||
|
| `zig` | zig |
|
||||||
|
| `nix` | nix |
|
||||||
|
| `php` | php |
|
||||||
|
| `html` | html |
|
||||||
|
| `css` | css |
|
||||||
|
| `scss` | sass |
|
||||||
|
| `less` | less |
|
||||||
|
| `json` | json |
|
||||||
|
| `yml`, `yaml` | yaml |
|
||||||
|
| `xml` | xml |
|
||||||
|
| `toml` | toml |
|
||||||
|
| `md`, `markdown` | markdown |
|
||||||
|
| `sql` | database |
|
||||||
|
| `graphql`, `gql` | graphql |
|
||||||
|
| `sh`, `bash` | console |
|
||||||
|
| `tf` | terraform |
|
||||||
|
| `hcl` | hcl |
|
||||||
|
| `vue` | vue |
|
||||||
|
| `svelte` | svelte |
|
||||||
|
| `astro` | astro |
|
||||||
|
| `wasm` | webassembly |
|
||||||
|
| `svg` | svg |
|
||||||
|
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||||
|
| `txt` | document |
|
||||||
|
| `conf`, `cfg`, `ini` | settings |
|
||||||
|
| `lock` | lock |
|
||||||
|
| `groovy` | groovy |
|
||||||
|
| `gradle` | gradle |
|
||||||
+16
-3
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
|
|||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:check # Validate release
|
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||||
|
# ... test builds from the draft release assets ...
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
npm run release:finalize # Publish npm, promote draft to published
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
|
|||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- `release:finalize` publishes npm and promotes the same draft release
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- Use the same semver tag for both; don't cut a second tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
|
|
||||||
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||||
|
|
||||||
|
## Changelog format
|
||||||
|
|
||||||
|
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||||
|
|
||||||
|
```
|
||||||
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] `npm run release:patch` completes successfully
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
|
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Storage Revamp Plan
|
||||||
|
|
||||||
|
Status: active rollout, phases 1 and 2 complete
|
||||||
|
|
||||||
|
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
|
||||||
|
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
|
||||||
|
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- make structured records durable in Drizzle + SQLite
|
||||||
|
- make projects and workspaces explicit first-class records
|
||||||
|
- stop deriving project/workspace identity from agent `cwd`
|
||||||
|
- keep agent snapshot persistence behind clear ownership
|
||||||
|
- move committed timeline history to storage-owned rows
|
||||||
|
- remove legacy JSON and in-memory authority once the DB path is proven
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- moving config, keypairs, push tokens, or server identity into the DB
|
||||||
|
- persisting raw provider deltas or transport-only chunk streams
|
||||||
|
- designing a hosted/remote database story beyond keeping the schema portable
|
||||||
|
- durable reasoning history unless product explicitly asks for it later
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
The storage revamp is no longer hypothetical.
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
|
||||||
|
- Drizzle + SQLite database bootstrap is in place
|
||||||
|
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
|
||||||
|
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
|
||||||
|
- `agent_snapshots.workspace_id` is `NOT NULL`
|
||||||
|
- legacy JSON import feeds the DB-backed structured records
|
||||||
|
- project/workspace records use explicit `directory` fields instead of path-as-identity
|
||||||
|
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
|
||||||
|
- `workspace-reconciliation-service.ts` is deleted
|
||||||
|
- `workspace-registry-bootstrap.ts` is deleted
|
||||||
|
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
|
||||||
|
|
||||||
|
Still pending:
|
||||||
|
|
||||||
|
- explicit `create_project` / `create_workspace` API cleanup
|
||||||
|
- final archive cascade behavior for descendants and live agents
|
||||||
|
- committed timeline storage cutover
|
||||||
|
- removal of remaining legacy JSON and in-memory committed-history authority
|
||||||
|
|
||||||
|
## Converged decisions
|
||||||
|
|
||||||
|
### Structured record authority
|
||||||
|
|
||||||
|
Projects, workspaces, and agent snapshots are DB-backed structured records.
|
||||||
|
The server should not recreate project/workspace identity from:
|
||||||
|
|
||||||
|
- git remotes
|
||||||
|
- worktree main-repo roots
|
||||||
|
- normalized cwd strings
|
||||||
|
|
||||||
|
Temporary exception:
|
||||||
|
|
||||||
|
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
|
||||||
|
`workspaceId` explicitly
|
||||||
|
|
||||||
|
That fallback is transitional and should be deleted once the client always sends the workspace id.
|
||||||
|
|
||||||
|
### Storage seams
|
||||||
|
|
||||||
|
The useful seams remain concrete and domain-shaped:
|
||||||
|
|
||||||
|
- `ProjectRegistry`
|
||||||
|
- `WorkspaceRegistry`
|
||||||
|
- `AgentSnapshotStore`
|
||||||
|
- `AgentTimelineStore`
|
||||||
|
|
||||||
|
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
|
||||||
|
|
||||||
|
### Timeline contract
|
||||||
|
|
||||||
|
The long-term timeline contract remains:
|
||||||
|
|
||||||
|
- committed rows are durable, canonical history
|
||||||
|
- provisional live updates are transient subscription state
|
||||||
|
- committed history is fetched by seq
|
||||||
|
- provider history replay is not the durability mechanism
|
||||||
|
|
||||||
|
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
|
||||||
|
stable DB-backed agent and workspace identity.
|
||||||
|
|
||||||
|
## Remaining phases
|
||||||
|
|
||||||
|
### Phase 3: Explicit creation and archive cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove the last transitional write paths that still infer state from directories.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- add explicit `create_project` handling
|
||||||
|
- add explicit `create_workspace` handling
|
||||||
|
- make agent creation require `workspaceId` once the UI is ready
|
||||||
|
- finish archive semantics for workspaces/projects and any descendant agent state
|
||||||
|
- remove the temporary find-or-create-by-directory fallback from agent creation
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- project/workspace creation is explicit end to end
|
||||||
|
- no normal creation path infers identity from cwd or git metadata
|
||||||
|
- archive flows behave consistently for structured records and live runtime state
|
||||||
|
|
||||||
|
### Phase 4: Timeline storage cutover
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Make committed history durable and storage-owned.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- make `AgentTimelineStore` authoritative for committed history
|
||||||
|
- write one committed row per finalized logical item
|
||||||
|
- support tail, before-seq, and after-seq queries from storage
|
||||||
|
- stop treating provider history hydration as the normal refresh/load path
|
||||||
|
- keep provisional live updates in memory only
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- committed history survives daemon restart
|
||||||
|
- reconnect uses committed catch-up plus future live events without gaps or duplicates
|
||||||
|
- unloaded agents can serve committed history from storage alone
|
||||||
|
|
||||||
|
### Phase 5: Legacy cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove compatibility paths after the DB-backed model is fully authoritative.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- remove legacy JSON authority for structured records
|
||||||
|
- remove in-memory committed-history ownership
|
||||||
|
- remove provider-history rehydrate compatibility paths
|
||||||
|
- trim dead protocol and reducer logic from the pre-storage model
|
||||||
|
- update architecture docs to match the final model
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- there is one durable storage path for structured records
|
||||||
|
- there is one durable storage path for committed timeline history
|
||||||
|
- the runtime no longer depends on the removed JSON/in-memory model
|
||||||
|
|
||||||
|
## Data model summary
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `git | directory`
|
||||||
|
- optional `git_remote`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Workspaces
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- belongs to a project by `project_id`
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `checkout | worktree`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Agent snapshots
|
||||||
|
|
||||||
|
- `agent_id` remains the primary key
|
||||||
|
- belongs to a workspace by integer `workspace_id`
|
||||||
|
- `workspace_id` is required
|
||||||
|
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
|
||||||
|
|
||||||
|
### Timeline rows
|
||||||
|
|
||||||
|
Target shape once Phase 4 lands:
|
||||||
|
|
||||||
|
- `agent_id`
|
||||||
|
- committed `seq`
|
||||||
|
- committed timestamp
|
||||||
|
- canonical finalized item payload
|
||||||
|
|
||||||
|
Not part of durable history:
|
||||||
|
|
||||||
|
- raw streaming chunks
|
||||||
|
- provisional assistant text
|
||||||
|
- provisional reasoning text
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
Every remaining phase should keep the same bar:
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- targeted tests for the touched storage/session/runtime paths
|
||||||
|
- migration/import coverage when storage authority changes
|
||||||
|
- reconnect and catch-up scenario coverage when timeline behavior changes
|
||||||
|
|
||||||
|
At minimum, timeline cutover must explicitly prove:
|
||||||
|
|
||||||
|
- `fetch-after-seq`
|
||||||
|
- `fetch-before-seq`
|
||||||
|
- restart durability
|
||||||
|
- no-gap/no-duplicate reconnect behavior
|
||||||
|
|
||||||
|
## Main risks
|
||||||
|
|
||||||
|
- timeline work reintroduces provider-history replay as hidden authority
|
||||||
|
- archive behavior diverges between stored records and live in-memory agents
|
||||||
|
- explicit creation work leaves the transitional cwd fallback in place too long
|
||||||
|
- cleanup stalls after compatibility paths stop being exercised
|
||||||
|
|
||||||
|
## Rule of thumb
|
||||||
|
|
||||||
|
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
|
||||||
|
it is probably moving in the wrong direction.
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Terminal Mode — Implementation Plan
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI.
|
||||||
|
|
||||||
|
**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### Phase 1: Server — Data Model & Provider Interface
|
||||||
|
|
||||||
|
#### 1.1 Add `terminal` flag to `ManagedAgentBase`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ManagedAgentBase = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal: boolean; // NEW — if true, this agent renders as a terminal TUI
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This flag is set at creation time and never changes. A terminal agent is always a terminal agent.
|
||||||
|
|
||||||
|
#### 1.2 Add `terminal` to `AgentSessionConfig`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentSessionConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal?: boolean; // NEW — create as terminal agent
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 Add `terminal` to the Zod schema
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
Add to `AgentSessionConfigSchema`:
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `AgentStateSchema` (the wire format sent to clients):
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 Add terminal command builders to `AgentClient`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TerminalCommand = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentClient {
|
||||||
|
// ...existing methods...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to launch this agent's TUI for a new session.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to resume an existing session in the agent's TUI.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5 Add `supportsTerminalMode` capability
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentCapabilityFlags = {
|
||||||
|
// ...existing flags...
|
||||||
|
supportsTerminalMode: boolean; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add to the Zod schema in `messages.ts`:
|
||||||
|
```typescript
|
||||||
|
supportsTerminalMode: z.boolean(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.6 Implement terminal command builders in providers
|
||||||
|
|
||||||
|
**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.modeId === "bypassPermissions") {
|
||||||
|
args.push("--dangerously-skip-permissions");
|
||||||
|
}
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
// mode mapping: default → nothing, plan → --plan, etc.
|
||||||
|
return { command: "claude", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "claude",
|
||||||
|
args: ["--resume", handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
if (config.modeId) args.push("--approval-mode", config.modeId);
|
||||||
|
return { command: "codex", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "codex",
|
||||||
|
args: ["--resume", handle.nativeHandle ?? handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
return { command: "opencode", args: [], env: {} };
|
||||||
|
}
|
||||||
|
// No resume support for OpenCode initially
|
||||||
|
```
|
||||||
|
|
||||||
|
Capabilities for each provider:
|
||||||
|
- Claude: `supportsTerminalMode: true`
|
||||||
|
- Codex: `supportsTerminalMode: true`
|
||||||
|
- OpenCode: `supportsTerminalMode: true`
|
||||||
|
|
||||||
|
#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
When `config.terminal === true`:
|
||||||
|
1. Do NOT call `client.createSession()` — there is no managed session
|
||||||
|
2. Call `client.buildTerminalCreateCommand(config)` to get the command
|
||||||
|
3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command
|
||||||
|
4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null`
|
||||||
|
5. Store the terminal ID in the agent's metadata or a new field
|
||||||
|
6. The agent's persistence handle can be populated later (the CLI will create its own session file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record<string, string> }): Promise<ManagedAgent> {
|
||||||
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
|
||||||
|
if (normalizedConfig.terminal) {
|
||||||
|
// Terminal mode — no managed session, just build the command
|
||||||
|
const buildCmd = client.buildTerminalCreateCommand;
|
||||||
|
if (!buildCmd) {
|
||||||
|
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||||
|
}
|
||||||
|
const cmd = buildCmd.call(client, normalizedConfig);
|
||||||
|
return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, {
|
||||||
|
labels: options?.labels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...existing managed agent flow...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New method `registerTerminalAgent()`:
|
||||||
|
- Creates a ManagedAgent with `terminal: true`
|
||||||
|
- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect)
|
||||||
|
- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state)
|
||||||
|
- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents)
|
||||||
|
- Broadcasts `agent_state` event so clients know about it
|
||||||
|
|
||||||
|
#### 1.8 New message: create terminal for agent
|
||||||
|
|
||||||
|
The client needs a way to request a terminal for a terminal agent. Options:
|
||||||
|
|
||||||
|
**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command.
|
||||||
|
|
||||||
|
**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step.
|
||||||
|
|
||||||
|
**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided:
|
||||||
|
- Look up the agent (must be a terminal agent)
|
||||||
|
- Use the agent's stored command to create the terminal
|
||||||
|
- Associate the terminal with the agent
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CreateTerminalRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("create_terminal_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.9 Terminal → Agent lifecycle binding
|
||||||
|
|
||||||
|
When a terminal associated with a terminal agent exits:
|
||||||
|
- Set agent lifecycle to `"closed"`
|
||||||
|
- Attempt to detect the agent's session file for persistence handle
|
||||||
|
- Broadcast state update
|
||||||
|
|
||||||
|
When a terminal agent is opened from the sessions page:
|
||||||
|
- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists
|
||||||
|
- Otherwise calls `buildTerminalCreateCommand(config)`
|
||||||
|
- Creates a new terminal with that command
|
||||||
|
|
||||||
|
#### 1.10 Extend `createTerminal()` to support command + args
|
||||||
|
|
||||||
|
**File:** `packages/server/src/terminal/terminal.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CreateTerminalOptions {
|
||||||
|
cwd: string;
|
||||||
|
shell?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
name?: string;
|
||||||
|
command?: string; // NEW — if provided, run this instead of shell
|
||||||
|
args?: string[]; // NEW — arguments for command
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `createTerminal()`:
|
||||||
|
```typescript
|
||||||
|
const spawnCommand = options.command ?? shell;
|
||||||
|
const spawnArgs = options.command ? (options.args ?? []) : [];
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cols, rows, cwd,
|
||||||
|
env: { ...process.env, ...env, TERM: "xterm-256color" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: App — Draft UI & Terminal Toggle
|
||||||
|
|
||||||
|
#### 2.1 Add terminal toggle to draft tab
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx`
|
||||||
|
|
||||||
|
Add a toggle switch in the draft UI: **"Chat" / "Terminal"**
|
||||||
|
|
||||||
|
State:
|
||||||
|
```typescript
|
||||||
|
const [isTerminalMode, setIsTerminalMode] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
The toggle should be persistent per draft (stored in the draft store or as a preference).
|
||||||
|
|
||||||
|
When terminal mode is selected:
|
||||||
|
- The provider/model pickers still work (same UI)
|
||||||
|
- The mode picker still works
|
||||||
|
- The "send" button label changes to "Launch" or "Start"
|
||||||
|
- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI)
|
||||||
|
|
||||||
|
#### 2.2 Modify agent creation to pass `terminal: true`
|
||||||
|
|
||||||
|
When the user submits a draft in terminal mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config: AgentSessionConfig = {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
model: selectedModel,
|
||||||
|
modeId: selectedMode,
|
||||||
|
terminal: true, // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed.
|
||||||
|
|
||||||
|
#### 2.3 Terminal mode in `AgentStatusBar`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/components/agent-status-bar.tsx`
|
||||||
|
|
||||||
|
When rendering a draft's status bar, filter the capability:
|
||||||
|
- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected
|
||||||
|
- The terminal toggle can live next to the provider selector or as a segmented control above the input area
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: App — Agent Panel Rendering
|
||||||
|
|
||||||
|
#### 3.1 Branch rendering in `AgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function AgentPanelContent({ agentId, ... }) {
|
||||||
|
const agent = useAgentState(agentId);
|
||||||
|
|
||||||
|
if (agent?.terminal) {
|
||||||
|
return <TerminalAgentPanel agentId={agentId} agent={agent} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AgentPanelBody agent={agent} ... />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 New component: `TerminalAgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file)
|
||||||
|
|
||||||
|
This component:
|
||||||
|
1. Gets the terminal ID associated with the agent (from agent metadata or a new field)
|
||||||
|
2. Renders a `TerminalPane` connected to that terminal session
|
||||||
|
3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })`
|
||||||
|
4. Handles terminal exit → agent close lifecycle
|
||||||
|
|
||||||
|
Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal.
|
||||||
|
|
||||||
|
#### 3.3 Tab descriptor for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor`
|
||||||
|
|
||||||
|
The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge.
|
||||||
|
|
||||||
|
Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Sessions Page
|
||||||
|
|
||||||
|
#### 4.1 Terminal agents appear in sessions list
|
||||||
|
|
||||||
|
No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`.
|
||||||
|
|
||||||
|
#### 4.2 Opening a terminal agent from sessions
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/sessions/` (sessions screen)
|
||||||
|
|
||||||
|
When the user clicks a closed terminal agent:
|
||||||
|
1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists
|
||||||
|
2. Creates a new terminal with that command
|
||||||
|
3. Opens agent tab in workspace
|
||||||
|
|
||||||
|
If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: CLI Gating
|
||||||
|
|
||||||
|
#### 5.1 `paseo send` — error for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/cli/src/commands/send.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (agent.terminal) {
|
||||||
|
throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 `paseo run` — could support `--terminal` flag (future)
|
||||||
|
|
||||||
|
Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only.
|
||||||
|
|
||||||
|
#### 5.3 `paseo ls` — show terminal flag
|
||||||
|
|
||||||
|
Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wire Format Changes Summary
|
||||||
|
|
||||||
|
### AgentSessionConfig (create request)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentState (server → client)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
lifecycle: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentCapabilityFlags
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
supportsStreaming: boolean;
|
||||||
|
supportsSessionPersistence: boolean;
|
||||||
|
+ supportsTerminalMode: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CreateTerminalRequest
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
type: "create_terminal_request";
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
+ agentId?: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TerminalCommand (new type)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases & Agent Assignments
|
||||||
|
|
||||||
|
### Phase 1: Server data model (1 agent)
|
||||||
|
- Add `terminal` to types, schemas, and agent manager
|
||||||
|
- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient`
|
||||||
|
- Add `supportsTerminalMode` capability flag
|
||||||
|
- Extend `createTerminal()` to support command+args
|
||||||
|
- Implement terminal agent creation flow in `AgentManager`
|
||||||
|
- Wire terminal exit → agent close lifecycle
|
||||||
|
- Implement command builders in Claude, Codex, OpenCode providers
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 2: App draft UI + terminal toggle (1 agent)
|
||||||
|
- Add terminal mode toggle to `workspace-draft-agent-tab.tsx`
|
||||||
|
- Pass `terminal: true` in config when toggle is on
|
||||||
|
- Filter toggle based on `supportsTerminalMode` capability
|
||||||
|
- Persist toggle preference
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 3: App panel rendering (1 agent)
|
||||||
|
- Branch `AgentPanelContent` on `agent.terminal`
|
||||||
|
- Create `TerminalAgentPanel` component
|
||||||
|
- Handle terminal creation for agent on open
|
||||||
|
- Handle terminal exit lifecycle
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 4: Sessions page + CLI gating (1 agent)
|
||||||
|
- Terminal agents show in sessions with badge
|
||||||
|
- Opening from sessions resumes or creates terminal
|
||||||
|
- `paseo send` errors for terminal agents
|
||||||
|
- `paseo ls` shows terminal badge
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Interaction Guards
|
||||||
|
|
||||||
|
Terminal agents are explicitly excluded from automated dispatch paths:
|
||||||
|
|
||||||
|
- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false`
|
||||||
|
- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false`
|
||||||
|
- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send
|
||||||
|
- **CLI `paseo send`**: Returns error for terminal agents
|
||||||
|
- **MCP agent creation**: Programmatic paths don't pass `terminal: true`
|
||||||
|
|
||||||
|
All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents.
|
||||||
|
|
||||||
|
## What This Does NOT Change
|
||||||
|
|
||||||
|
- The existing managed agent flow is untouched
|
||||||
|
- Terminal sessions (non-agent) still work as before
|
||||||
|
- The `AgentSession` interface is unchanged
|
||||||
|
- Mobile experience is unchanged (terminal mode is web/desktop only for now)
|
||||||
|
- No new providers are added (existing providers gain terminal command builders)
|
||||||
|
- No hooks, no env injection, no process tree detection (v1 keeps it simple)
|
||||||
|
|
||||||
|
## Future Work (Not In This Plan)
|
||||||
|
|
||||||
|
- Auto-detect agent type from PTY process tree (for standalone terminals)
|
||||||
|
- "Convert to chat" / "Convert to terminal" actions
|
||||||
|
- Terminal title/icon from OSC sequences
|
||||||
|
- `paseo run --terminal` CLI support
|
||||||
|
- Mobile terminal mode (if xterm.js works well enough on mobile web)
|
||||||
|
- Gemini / Aider / Goose provider definitions (terminal-only providers)
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772963539,
|
||||||
|
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = paseo;
|
||||||
|
paseo = paseo;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = self.nixosModules.paseo;
|
||||||
|
nixosModules.paseo =
|
||||||
|
{ pkgs, lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./nix/module.nix ];
|
||||||
|
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
pkgs.python3
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.paseo;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.paseo = {
|
||||||
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "paseo" { };
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "User account under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "Group under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default =
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/${cfg.user}/.paseo";
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/''${cfg.user}/.paseo"
|
||||||
|
'';
|
||||||
|
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 6767;
|
||||||
|
description = "Port for the Paseo daemon to listen on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
listenAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address for the Paseo daemon to bind to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedHosts = lib.mkOption {
|
||||||
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
|
default = [ ];
|
||||||
|
example = [ ".example.com" "myhost.local" ];
|
||||||
|
description = ''
|
||||||
|
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||||
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
|
Use a leading dot to match a domain and all its subdomains
|
||||||
|
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||||
|
|
||||||
|
Set to `true` to allow any host (not recommended).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
relay = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
inheritUserEnvironment = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.user != "paseo";
|
||||||
|
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||||
|
description = ''
|
||||||
|
Whether to include the user's profile PATH in the service environment.
|
||||||
|
|
||||||
|
When Paseo runs as a real user (not the default system user), AI agents
|
||||||
|
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||||
|
NixOS profile and system paths so agents can use them without manually
|
||||||
|
setting PATH.
|
||||||
|
|
||||||
|
Enabled by default when `user` is set to a non-default value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = "Extra environment variables for the Paseo daemon.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.paseo = {
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PASEO_HOME = cfg.dataDir;
|
||||||
|
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||||
|
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||||
|
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||||
|
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||||
|
# includes /run/current-system/sw/bin which is a superset of those.
|
||||||
|
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||||
|
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||||
|
"/run/current-system/sw/bin"
|
||||||
|
"/run/wrappers/bin"
|
||||||
|
"/nix/var/nix/profiles/default/bin"
|
||||||
|
]);
|
||||||
|
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||||
|
PASEO_ALLOWED_HOSTS = "true";
|
||||||
|
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||||
|
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
|
||||||
|
ExecStart =
|
||||||
|
"${cfg.package}/bin/paseo-server"
|
||||||
|
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
TimeoutStopSec = 15;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenv,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs_22,
|
||||||
|
python3,
|
||||||
|
makeWrapper,
|
||||||
|
# node-pty needs libuv headers on Linux
|
||||||
|
libuv,
|
||||||
|
}:
|
||||||
|
|
||||||
|
buildNpmPackage rec {
|
||||||
|
pname = "paseo";
|
||||||
|
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = ./..;
|
||||||
|
filter = path: type:
|
||||||
|
let
|
||||||
|
baseName = builtins.baseNameOf path;
|
||||||
|
relPath = lib.removePrefix (toString ./..) path;
|
||||||
|
in
|
||||||
|
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||||
|
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||||
|
# Exclude test fixtures and debug files
|
||||||
|
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||||
|
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||||
|
&& baseName != "node_modules"
|
||||||
|
&& baseName != ".git"
|
||||||
|
&& baseName != ".paseo"
|
||||||
|
&& baseName != ".DS_Store";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
|
||||||
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
|
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||||
|
|
||||||
|
# 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 (including supervisor-entrypoint) needed by CLI
|
||||||
|
if [ -d packages/server/dist/scripts ]; then
|
||||||
|
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||||
|
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create wrapper for the server entry point (for systemd / direct use)
|
||||||
|
mkdir -p $out/bin
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||||
|
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||||
|
--set NODE_ENV production
|
||||||
|
|
||||||
|
# Create wrapper for the CLI
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||||
|
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||||
|
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||||
|
homepage = "https://github.com/getpaseo/paseo";
|
||||||
|
license = lib.licenses.agpl3Plus;
|
||||||
|
mainProgram = "paseo";
|
||||||
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+1561
-50
File diff suppressed because it is too large
Load Diff
+20
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||||
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
@@ -44,18 +45,18 @@
|
|||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.8",
|
"@biomejs/biome": "^2.4.8",
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -76,7 +78,14 @@
|
|||||||
"development",
|
"development",
|
||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
"author": "moboudra",
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
|
|||||||
@@ -448,12 +448,15 @@ export default async function globalSetup() {
|
|||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_DICTATION_ENABLED: "1",
|
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_VOICE_MODE_ENABLED: "1",
|
...(openAiUsable
|
||||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
? {
|
||||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
NODE_ENV: "development",
|
NODE_ENV: "development",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||||
|
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||||
|
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
await page.goto(route);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
|
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||||
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||||
|
if (testId) ids.push(testId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||||
|
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||||
|
const ids = await getTabTestIds(page);
|
||||||
|
return ids.filter((id) => id.includes(kind)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||||
|
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||||
|
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||||
|
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||||
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
|
return activeTab.getAttribute("data-testid");
|
||||||
|
}
|
||||||
|
// Fallback: the tab with focused styling
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||||
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-tab");
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press Cmd+T (macOS) to open a new tab. */
|
||||||
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press("Meta+t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the launcher panel to render with its primary tiles. */
|
||||||
|
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||||
|
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "New Chat" tile. */
|
||||||
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "Terminal" tile. */
|
||||||
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the "New Chat" tile on the launcher panel. */
|
||||||
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the "Terminal" tile on the launcher panel. */
|
||||||
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||||
|
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||||
|
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||||
|
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||||
|
await tile.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for any tab in the bar to display the given title text. */
|
||||||
|
export async function waitForTabWithTitle(
|
||||||
|
page: Page,
|
||||||
|
title: string | RegExp,
|
||||||
|
timeout = 30_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||||
|
.toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||||
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
|
const buttons = page.getByTestId("workspace-new-tab");
|
||||||
|
// There might be multiple panes, each with a "+" button
|
||||||
|
// But within a single pane there should only be one
|
||||||
|
const count = await buttons.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||||
|
* Returns elapsed milliseconds.
|
||||||
|
*/
|
||||||
|
export async function measureTileTransition(
|
||||||
|
page: Page,
|
||||||
|
clickAction: () => Promise<void>,
|
||||||
|
successLocator: ReturnType<Page["locator"]>,
|
||||||
|
timeout = 5_000,
|
||||||
|
): Promise<number> {
|
||||||
|
const start = Date.now();
|
||||||
|
await clickAction();
|
||||||
|
await expect(successLocator).toBeVisible({ timeout });
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||||
|
* Returns all unique snapshots observed.
|
||||||
|
*/
|
||||||
|
export async function sampleTabsDuringTransition(
|
||||||
|
page: Page,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
durationMs = 2_000,
|
||||||
|
intervalMs = 30,
|
||||||
|
): Promise<string[][]> {
|
||||||
|
const snapshots: string[][] = [];
|
||||||
|
const startSampling = async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < durationMs) {
|
||||||
|
snapshots.push(await getTabTestIds(page));
|
||||||
|
await page.waitForTimeout(intervalMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const samplingPromise = startSampling();
|
||||||
|
await action();
|
||||||
|
await samplingPromise;
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||||
|
return createTempGitRepo(prefix);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
|
export type TerminalPerfDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createTerminal(
|
||||||
|
cwd: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<{
|
||||||
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
sendTerminalInput(
|
||||||
|
terminalId: string,
|
||||||
|
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||||
|
): void;
|
||||||
|
onTerminalStreamEvent(
|
||||||
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
|
): () => void;
|
||||||
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => TerminalPerfDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||||
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
if (line) {
|
||||||
|
lines.push(line.translateToString(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalContent(
|
||||||
|
page: Page,
|
||||||
|
predicate: (text: string) => boolean,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const text = await getTerminalBufferText(page);
|
||||||
|
if (predicate(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTerminal(
|
||||||
|
page: Page,
|
||||||
|
input: { cwd: string; terminalId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
// Boot the app at the workspace route directly.
|
||||||
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
|
// so the daemon registry is already configured when the app starts.
|
||||||
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||||
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
|
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// The workspace should now query listTerminals and discover our terminal.
|
||||||
|
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||||
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!surfaceVisible) {
|
||||||
|
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||||
|
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (tabExists) {
|
||||||
|
await terminalTab.click();
|
||||||
|
} else {
|
||||||
|
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||||
|
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||||
|
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
|
await newTerminalBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal surface to be visible
|
||||||
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => {
|
||||||
|
// overlay may never appear if attachment is instant
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalSurface.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||||
|
|
||||||
|
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatencySample = {
|
||||||
|
char: string;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures keystroke echo round-trip latency.
|
||||||
|
*
|
||||||
|
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||||
|
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||||
|
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||||
|
*/
|
||||||
|
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
throw new Error("__paseoTerminal not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ((window as any).__perfKeystroke = {
|
||||||
|
promise: null as Promise<number> | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.promise = new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
reject(new Error("keystroke echo timeout (5s)"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
function onKeyDown() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
const start = performance.now();
|
||||||
|
const disposable = term.onWriteParsed(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
disposable.dispose();
|
||||||
|
resolve(performance.now() - start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(char);
|
||||||
|
|
||||||
|
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
gotoWorkspace,
|
||||||
|
waitForLauncherPanel,
|
||||||
|
assertProviderTilesVisible,
|
||||||
|
assertNewChatTileVisible,
|
||||||
|
assertTerminalTileVisible,
|
||||||
|
assertSingleNewTabButton,
|
||||||
|
clickNewTabButton,
|
||||||
|
pressNewTabShortcut,
|
||||||
|
clickNewChat,
|
||||||
|
clickTerminal,
|
||||||
|
clickProviderTile,
|
||||||
|
countTabsOfKind,
|
||||||
|
getTabTestIds,
|
||||||
|
waitForTabWithTitle,
|
||||||
|
measureTileTransition,
|
||||||
|
sampleTabsDuringTransition,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
waitForTerminalContent,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (tempRepo) await tempRepo.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Launcher Tab Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher tab", () => {
|
||||||
|
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await assertNewChatTileVisible(page);
|
||||||
|
await assertTerminalTileVisible(page);
|
||||||
|
await assertProviderTilesVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||||
|
|
||||||
|
await clickNewChat(page);
|
||||||
|
|
||||||
|
// Draft composer should appear (the agent message input)
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Launcher tab should have been replaced (not added alongside)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||||
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
|
|
||||||
|
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||||
|
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||||
|
// Total tab count should stay the same (replaced, not added)
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
// Terminal surface should appear
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (in-place replacement)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
|
||||||
|
// The launcher tab is gone, a terminal tab exists
|
||||||
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
|
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
// Click the first visible provider tile under "Terminal Agents"
|
||||||
|
const providerTiles = page.locator('[role="button"]').filter({
|
||||||
|
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||||
|
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||||
|
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The provider grid follows the label. Click the first provider tile.
|
||||||
|
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||||
|
const firstProvider = providerGrid.getByRole("button").first();
|
||||||
|
if (await firstProvider.isVisible().catch(() => false)) {
|
||||||
|
await firstProvider.click();
|
||||||
|
} else {
|
||||||
|
// Fallback: look for any provider button after the section label
|
||||||
|
const allButtons = page.getByRole("button");
|
||||||
|
const count = await allButtons.count();
|
||||||
|
let clicked = false;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = allButtons.nth(i);
|
||||||
|
const text = await btn.innerText().catch(() => "");
|
||||||
|
// Skip known non-provider buttons
|
||||||
|
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
await btn.click();
|
||||||
|
clicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!clicked) {
|
||||||
|
test.skip(true, "No provider tiles available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should see an agent panel (terminal surface or agent stream)
|
||||||
|
const agentOrTerminal = page.locator(
|
||||||
|
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||||
|
);
|
||||||
|
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (replaced, not added)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await assertSingleNewTabButton(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Terminal Title Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Terminal title propagation", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to workspace and open the terminal
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Send OSC 0 (set window title) escape sequence
|
||||||
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// Wait for the tab to reflect the new title
|
||||||
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Fire many rapid title changes — only the last should stick
|
||||||
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||||
|
}
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// The tab should eventually settle on the final title
|
||||||
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// No-Flash Transition Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher transitions (no flash)", () => {
|
||||||
|
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
// Sample tabs at high frequency across the transition
|
||||||
|
const snapshots = await sampleTabsDuringTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
2_000,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||||
|
const counts = snapshots.map((s) => s.length);
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
|
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||||
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickTerminal(page),
|
||||||
|
terminal.first(),
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Terminal surface should appear within a reasonable budget.
|
||||||
|
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||||
|
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||||
|
expect(elapsed).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
composer,
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draft replacement is fully in-memory — should be fast
|
||||||
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
// is that no blank/flash frame appears (tested above).
|
||||||
|
expect(elapsed).toBeLessThan(3_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
|
measureKeystrokeLatency,
|
||||||
|
computePercentile,
|
||||||
|
round2,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
type LatencySample,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const LINE_COUNT = 50_000;
|
||||||
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
|
||||||
|
test.describe("Terminal wire performance", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
if (tempRepo) {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - startMs;
|
||||||
|
|
||||||
|
// seq 1 N outputs each number on its own line
|
||||||
|
const estimatedBytes = Array.from(
|
||||||
|
{ length: LINE_COUNT },
|
||||||
|
(_, i) => String(i + 1).length + 1,
|
||||||
|
).reduce((a, b) => a + b, 0);
|
||||||
|
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
lineCount: LINE_COUNT,
|
||||||
|
estimatedBytes,
|
||||||
|
elapsedMs,
|
||||||
|
throughputMBps: round2(throughputMBps),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("throughput-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||||
|
THROUGHPUT_BUDGET_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Ensure clean prompt state
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const samples: LatencySample[] = [];
|
||||||
|
const chars = "abcdefghijklmnopqrst";
|
||||||
|
|
||||||
|
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
|
||||||
|
const char = chars[i % chars.length];
|
||||||
|
const latencyMs = await measureKeystrokeLatency(page, char);
|
||||||
|
samples.push({ char, latencyMs });
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up typed characters
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
|
||||||
|
const latencies = samples.map((s) => s.latencyMs);
|
||||||
|
const p50 = computePercentile(latencies, 50);
|
||||||
|
const p95 = computePercentile(latencies, 95);
|
||||||
|
const max = Math.max(...latencies);
|
||||||
|
const min = Math.min(...latencies);
|
||||||
|
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
sampleCount: KEYSTROKE_SAMPLE_COUNT,
|
||||||
|
p50Ms: round2(p50),
|
||||||
|
p95Ms: round2(p95),
|
||||||
|
maxMs: round2(max),
|
||||||
|
minMs: round2(min),
|
||||||
|
avgMs: round2(avg),
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
char: s.char,
|
||||||
|
latencyMs: round2(s.latencyMs),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("latency-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
p95,
|
||||||
|
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||||
|
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.32",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.32",
|
"@getpaseo/expo-two-way-audio": "0.1.38",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.38",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.38",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
export const WELCOME_ROUTE = "/welcome";
|
|
||||||
|
|
||||||
export function shouldWaitOnStartupRace(input: {
|
|
||||||
onlineServerId: string | null;
|
|
||||||
hasTimedOut: boolean;
|
|
||||||
isDesktopStartupRace: boolean;
|
|
||||||
daemonCount: number;
|
|
||||||
pathname: string;
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.pathname === WELCOME_ROUTE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.hasTimedOut) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRedirectToWelcome(input: {
|
|
||||||
onlineServerId: string | null;
|
|
||||||
hasTimedOut: boolean;
|
|
||||||
pathname: string;
|
|
||||||
isDesktopStartupRace: boolean;
|
|
||||||
daemonCount: number;
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId || !input.hasTimedOut) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.pathname !== "/" && input.pathname !== "") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, ActivityIndicator, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { darkTheme } from "@/styles/theme";
|
import { darkTheme } from "@/styles/theme";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -25,10 +25,14 @@ import {
|
|||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
} from "@/runtime/host-runtime";
|
} 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 { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -57,6 +61,7 @@ import { getIsDesktop } from "@/constants/layout";
|
|||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +70,7 @@ import {
|
|||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -76,7 +82,18 @@ import {
|
|||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
const HostRuntimeBootstrapContext = createContext(false);
|
|
||||||
|
export type HostRuntimeBootstrapState = {
|
||||||
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
|
phase: "starting-daemon",
|
||||||
|
error: null,
|
||||||
|
retry: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -204,41 +221,99 @@ function HostSessionManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
void store
|
const init = async () => {
|
||||||
.loadFromStorage()
|
const settings = await loadSettingsFromStorage();
|
||||||
.then(() => {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReady(true);
|
|
||||||
void store.bootstrap();
|
setPhase("connecting");
|
||||||
})
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
.catch((error) => {
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
console.error("[HostRuntime] Failed to initialize store", error);
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setReady(true);
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
});
|
} 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [retryToken]);
|
||||||
|
|
||||||
|
const state = useMemo<HostRuntimeBootstrapState>(
|
||||||
|
() => ({
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
}),
|
||||||
|
[error, phase, retry],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
<HostRuntimeBootstrapContext.Provider value={state}>
|
||||||
{children}
|
{children}
|
||||||
</HostRuntimeBootstrapContext.Provider>
|
</HostRuntimeBootstrapContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStoreReady(): boolean {
|
export function useStoreReady(): boolean {
|
||||||
|
return useContext(HostRuntimeBootstrapContext).phase === "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
||||||
return useContext(HostRuntimeBootstrapContext);
|
return useContext(HostRuntimeBootstrapContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +372,7 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -399,24 +475,30 @@ function MobileGestureWrapper({
|
|||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const isLoading = settingsLoading || !storeReady;
|
const systemColorScheme = useColorScheme();
|
||||||
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (settingsLoading) return;
|
||||||
if (settings.theme === "auto") {
|
if (settings.theme === "auto") {
|
||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(settings.theme);
|
UnistylesRuntime.setTheme(settings.theme);
|
||||||
}
|
}
|
||||||
}, [isLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
if (isLoading) {
|
useEffect(() => {
|
||||||
return <LoadingView />;
|
if (settingsLoading || Platform.OS !== "web") {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||||
|
});
|
||||||
|
}, [settingsLoading, resolvedTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
@@ -519,6 +601,38 @@ function FaviconStatusSync() {
|
|||||||
return null;
|
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() {
|
function NavigationActiveWorkspaceObserver() {
|
||||||
const navigationRef = useNavigationContainerRef();
|
const navigationRef = useNavigationContainerRef();
|
||||||
|
|
||||||
@@ -539,57 +653,6 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
return null;
|
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() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||||
@@ -606,28 +669,7 @@ export default function RootLayout() {
|
|||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<Stack
|
<RootStack />
|
||||||
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>
|
</AppWithSidebar>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
buildHostWorkspaceOpenRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
@@ -68,7 +69,9 @@ export default function HostIndexRoute() {
|
|||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
router.replace(
|
||||||
|
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
|
||||||
import {
|
import {
|
||||||
shouldRedirectToWelcome,
|
useHostRuntimeBootstrapState,
|
||||||
shouldWaitOnStartupRace,
|
useStoreReady,
|
||||||
WELCOME_ROUTE,
|
} from "@/app/_layout";
|
||||||
} from "@/app-support/index-startup";
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const STARTUP_TIMEOUT_MS = 30_000;
|
const WELCOME_ROUTE = "/welcome";
|
||||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
|
||||||
|
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => {
|
() => {
|
||||||
@@ -33,81 +35,31 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
}
|
}
|
||||||
return firstOnlineServerId;
|
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() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
const storeReady = useStoreReady();
|
||||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
const hosts = useHosts();
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setHasTimedOut(true);
|
|
||||||
}, STARTUP_TIMEOUT_MS);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onlineServerId) {
|
if (!storeReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathname !== "/" && pathname !== "") {
|
if (pathname !== "/" && pathname !== "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
|
||||||
}, [onlineServerId, pathname, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const targetRoute = anyOnlineServerId
|
||||||
if (
|
? buildHostRootRoute(anyOnlineServerId)
|
||||||
!shouldRedirectToWelcome({
|
: WELCOME_ROUTE;
|
||||||
onlineServerId,
|
router.replace(targetRoute as any);
|
||||||
hasTimedOut,
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
pathname,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(WELCOME_ROUTE as any);
|
|
||||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
|
||||||
|
|
||||||
if (
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
shouldWaitOnStartupRace({
|
|
||||||
onlineServerId,
|
|
||||||
hasTimedOut,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
pathname,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return <StartupSplashScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onlineServerId) {
|
|
||||||
return <WelcomeScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,23 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|
||||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function LegacySettingsRoute() {
|
export default function LegacySettingsRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
|
||||||
|
|
||||||
const targetServerId = useMemo(() => {
|
const targetServerId = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preferences.serverId) {
|
|
||||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
|
||||||
if (match) {
|
|
||||||
return match.serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0]?.serverId ?? null;
|
return daemons[0]?.serverId ?? null;
|
||||||
}, [daemons, preferences.serverId]);
|
}, [daemons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferencesLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||||
}, [preferencesLoading, router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (preferencesLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return <DraftAgentScreen />;
|
return <DraftAgentScreen />;
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
|
|||||||
function getModeColor(modeId?: string): string {
|
function getModeColor(modeId?: string): string {
|
||||||
if (!modeId) return "#9ca3af"; // gray
|
if (!modeId) return "#9ca3af"; // gray
|
||||||
|
|
||||||
// Color based on common mode types
|
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
|
||||||
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
|
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
|
||||||
if (modeId.includes("code")) return "#22c55e"; // green - writes code
|
return "#3b82f6"; // blue - build/auto
|
||||||
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
|
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
|
||||||
|
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
|
||||||
|
return "#22c55e"; // green - safest
|
||||||
|
|
||||||
return "#9ca3af"; // gray - unknown
|
return "#9ca3af"; // gray - unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,14 +537,10 @@ export function AgentConfigRow({
|
|||||||
}, [modeOptions]);
|
}, [modeOptions]);
|
||||||
|
|
||||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
return models.map((model) => ({
|
||||||
for (const model of models) {
|
id: model.id,
|
||||||
opts.push({
|
label: model.label,
|
||||||
id: model.id,
|
}));
|
||||||
label: model.label,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
||||||
@@ -586,7 +582,7 @@ export function AgentConfigRow({
|
|||||||
title="Select model"
|
title="Select model"
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
options={modelSelectOptions}
|
options={modelSelectOptions}
|
||||||
placeholder="Auto"
|
placeholder={isModelLoading ? "Loading..." : "Select model"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
@@ -777,10 +773,8 @@ export function ModelDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel = selectedModel
|
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||||
: "Automatic";
|
|
||||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
|
||||||
const helperText = error
|
const helperText = error
|
||||||
? undefined
|
? undefined
|
||||||
: isLoading
|
: isLoading
|
||||||
@@ -790,34 +784,20 @@ export function ModelDropdown({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [
|
return models.map((model) => ({
|
||||||
{
|
id: model.id,
|
||||||
id: "",
|
label: model.label,
|
||||||
label: "Automatic (provider default)",
|
description: model.description,
|
||||||
description: "Let the assistant pick the recommended model.",
|
}));
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const model of models) {
|
|
||||||
opts.push({
|
|
||||||
id: model.id,
|
|
||||||
label: model.label,
|
|
||||||
description: model.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === "") {
|
onSelect(id);
|
||||||
onClear();
|
|
||||||
} else {
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onClear, onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
|
|||||||
try {
|
try {
|
||||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||||
input.clearDraft("sent");
|
input.clearDraft("sent");
|
||||||
|
input.setIsProcessing(false);
|
||||||
return "submitted";
|
return "submitted";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
input.onSubmitError?.(error);
|
input.onSubmitError?.(error);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive } from "lucide-react-native";
|
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -130,6 +131,7 @@ function SessionRow({
|
|||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const statusLabel = formatStatusLabel(agent.status);
|
const statusLabel = formatStatusLabel(agent.status);
|
||||||
const projectPath = shortenPath(agent.cwd);
|
const projectPath = shortenPath(agent.cwd);
|
||||||
|
const ProviderIcon = getProviderIcon(agent.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -145,12 +147,21 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<View style={styles.rowTitleRow}>
|
<View style={styles.rowTitleRow}>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{agent.title || "New session"}
|
{agent.title || "New session"}
|
||||||
</Text>
|
</Text>
|
||||||
|
{agent.terminal ? (
|
||||||
|
<SessionBadge
|
||||||
|
label="Terminal"
|
||||||
|
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{agent.archivedAt ? (
|
{agent.archivedAt ? (
|
||||||
<SessionBadge
|
<SessionBadge
|
||||||
label="Archived"
|
label="Archived"
|
||||||
@@ -239,6 +250,7 @@ export function AgentList({
|
|||||||
workspaceId: agent.cwd,
|
workspaceId: agent.cwd,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
|
requestReopen: agent.terminal && agent.status === "closed",
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
},
|
},
|
||||||
@@ -433,6 +445,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
rowMetaRow: {
|
rowMetaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty and default values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
expect(normalizeModelId(" default ")).toBeNull();
|
|
||||||
expect(normalizeModelId(undefined)).toBeNull();
|
expect(normalizeModelId(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns trimmed model ids", () => {
|
it("returns trimmed model ids", () => {
|
||||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||||
|
expect(normalizeModelId(" default ")).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
|
|||||||
expect(selection.selectedThinkingId).toBe("high");
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
expect(selection.displayThinking).toBe("High");
|
expect(selection.displayThinking).toBe("High");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model label instead of Auto", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
provider: "codex",
|
||||||
|
label: "Model A",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: null,
|
||||||
|
configuredModelId: null,
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.displayModel).toBe("Model A");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
provider: "claude",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||||
|
configuredModelId: "default",
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.activeModelId).toBe("default");
|
||||||
|
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||||
|
expect(selection.selectedThinkingId).toBe("low");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -104,21 +107,18 @@ function getModeIconColor(
|
|||||||
palette: {
|
palette: {
|
||||||
blue: { 500: string };
|
blue: { 500: string };
|
||||||
green: { 500: string };
|
green: { 500: string };
|
||||||
amber: { 500: string };
|
|
||||||
red: { 500: string };
|
red: { 500: string };
|
||||||
purple: { 500: string };
|
purple: { 500: string };
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
switch (colorTier) {
|
switch (colorTier) {
|
||||||
case "default":
|
|
||||||
return palette.blue[500];
|
|
||||||
case "safe":
|
case "safe":
|
||||||
return palette.green[500];
|
return palette.green[500];
|
||||||
case "moderate":
|
case "moderate":
|
||||||
return palette.amber[500];
|
return palette.blue[500];
|
||||||
case "dangerous":
|
case "dangerous":
|
||||||
return palette.red[500];
|
return palette.red[500];
|
||||||
case "readonly":
|
case "planning":
|
||||||
return palette.purple[500];
|
return palette.purple[500];
|
||||||
default:
|
default:
|
||||||
return palette.blue[500];
|
return palette.blue[500];
|
||||||
@@ -166,8 +166,12 @@ function ControlledStatusBar({
|
|||||||
const displayModel =
|
const displayModel =
|
||||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||||
? "Loading models..."
|
? "Loading models..."
|
||||||
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
: findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
const displayThinking = findOptionLabel(
|
||||||
|
thinkingOptions,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||||
|
);
|
||||||
|
|
||||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
@@ -603,8 +607,30 @@ function ControlledStatusBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MODES: AgentMode[] = [];
|
||||||
|
|
||||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
const { 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 client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const modelsQuery = useQuery({
|
||||||
@@ -631,23 +657,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
const models = modelsQuery.data ?? null;
|
const models = modelsQuery.data ?? null;
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
agent?.currentModeId ||
|
agent?.currentModeId ||
|
||||||
"default";
|
"default";
|
||||||
|
|
||||||
const modelSelection = resolveAgentModelSelection({
|
const modelSelection = resolveAgentModelSelection({
|
||||||
models,
|
models,
|
||||||
runtimeModelId: agent?.runtimeInfo?.model,
|
runtimeModelId: agent?.runtimeModelId,
|
||||||
configuredModelId: agent?.model,
|
configuredModelId: agent?.model,
|
||||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (agent?.availableModes ?? []).map((mode) => ({
|
return availableModes.map((mode) => ({
|
||||||
id: mode.id,
|
id: mode.id,
|
||||||
label: mode.label,
|
label: mode.label,
|
||||||
}));
|
}));
|
||||||
}, [agent?.availableModes]);
|
}, [availableModes]);
|
||||||
|
|
||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
@@ -668,9 +694,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={agent.provider}
|
provider={agent.provider}
|
||||||
modeOptions={
|
modeOptions={
|
||||||
modeOptions.length > 0
|
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
? modeOptions
|
|
||||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
@@ -687,6 +711,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
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) => {
|
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||||
});
|
});
|
||||||
@@ -697,6 +732,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
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) => {
|
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||||
});
|
});
|
||||||
@@ -777,7 +829,7 @@ export function DraftAgentStatusBar({
|
|||||||
label: definition.label,
|
label: definition.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
const modelOptions: StatusOption[] = [];
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
modelOptions.push({ id: model.id, label: model.label });
|
modelOptions.push({ id: model.id, label: model.label });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
|||||||
|
|
||||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
|
|||||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
const runtimeSelectedModel =
|
||||||
|
models && normalizedRuntimeModelId
|
||||||
|
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
|
||||||
|
: null;
|
||||||
|
const preferredModelId =
|
||||||
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
|
const fallbackModel =
|
||||||
|
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
: null;
|
: fallbackModel;
|
||||||
|
|
||||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
const displayModel =
|
||||||
|
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
|
||||||
|
|
||||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||||
const selectedThinkingId =
|
const resolvedThinkingId =
|
||||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||||
? explicitThinkingOptionId
|
? explicitThinkingOptionId
|
||||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||||
const selectedThinking =
|
const selectedThinking =
|
||||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||||
const displayThinking =
|
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||||
selectedThinking?.label ??
|
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
|
|||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agent: Agent;
|
agent: AgentScreenAgent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
|
|||||||
|
|
||||||
type DrillDownView = { provider: string };
|
type DrillDownView = { provider: string };
|
||||||
|
|
||||||
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
|
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||||
|
}
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
|
|||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? "Loading..." : "Auto";
|
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||||
const model = models.find((m) => m.id === selectedModel);
|
const model = models.find((m) => m.id === selectedModel);
|
||||||
return model?.label ?? "Auto";
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||||
|
|
||||||
describe("resolveStatusControlMode", () => {
|
describe("resolveStatusControlMode", () => {
|
||||||
it("uses ready mode when no controlled status controls are provided", () => {
|
it("uses ready mode when no controlled status controls are provided", () => {
|
||||||
+32
-56
@@ -1,10 +1,10 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -56,10 +56,12 @@ type QueuedMessage = {
|
|||||||
|
|
||||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
isInputActive: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
@@ -88,10 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
|||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
|
||||||
export function AgentInputArea({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
isInputActive,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
|
allowEmptySubmit = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -107,13 +111,11 @@ export function AgentInputArea({
|
|||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
statusControls,
|
statusControls,
|
||||||
}: AgentInputAreaProps) {
|
}: ComposerProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isScreenFocused = useIsFocused();
|
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
@@ -127,7 +129,14 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
const agentState = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
|
return {
|
||||||
|
status: agent?.status ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const queuedMessagesRaw = useSessionStore((state) =>
|
const queuedMessagesRaw = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||||
@@ -260,7 +269,6 @@ export function AgentInputArea({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
await client.sendAgentMessage(agentId, text, {
|
await client.sendAgentMessage(agentId, text, {
|
||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
@@ -274,41 +282,8 @@ export function AgentInputArea({
|
|||||||
onSubmitMessageRef.current = onSubmitMessage;
|
onSubmitMessageRef.current = onSubmitMessage;
|
||||||
}, [onSubmitMessage]);
|
}, [onSubmitMessage]);
|
||||||
|
|
||||||
const isAgentRunning = agent?.status === "running";
|
const isAgentRunning = agentState.status === "running";
|
||||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
const hasAgent = agentState.status !== null;
|
||||||
|
|
||||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
|
||||||
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
|
||||||
useEffect(() => {
|
|
||||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
|
||||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
|
||||||
if (isProcessing && !isAgentRunning) {
|
|
||||||
prevIsAgentRunningRef.current = false;
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasRunning = prevIsAgentRunningRef.current;
|
|
||||||
let shouldClearProcessing = false;
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
const hasEnteredRunning = !wasRunning && isAgentRunning;
|
|
||||||
const hasFreshRunningUpdateWhileRunning =
|
|
||||||
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
|
|
||||||
const hasStoppedRunning = wasRunning && !isAgentRunning;
|
|
||||||
|
|
||||||
shouldClearProcessing =
|
|
||||||
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIsAgentRunningRef.current = isAgentRunning;
|
|
||||||
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
|
||||||
|
|
||||||
if (shouldClearProcessing) {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const updateQueue = useCallback(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||||
@@ -350,7 +325,7 @@ export function AgentInputArea({
|
|||||||
message,
|
message,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
forceSend,
|
forceSend,
|
||||||
isAgentRunning: agent?.status === "running",
|
isAgentRunning: agentState.status === "running",
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
@@ -424,7 +399,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isScreenFocused) {
|
if (!isInputActive) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +435,7 @@ export function AgentInputArea({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isScreenFocused],
|
[isInputActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -472,9 +447,9 @@ export function AgentInputArea({
|
|||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
enabled: isScreenFocused,
|
enabled: isInputActive,
|
||||||
priority: isMessageInputFocused ? 200 : 100,
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
isActive: () => isScreenFocused,
|
isActive: () => isInputActive,
|
||||||
handle: handleKeyboardAction,
|
handle: handleKeyboardAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -483,7 +458,7 @@ export function AgentInputArea({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleCancelAgent() {
|
function handleCancelAgent() {
|
||||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
if (!isAgentRunning || isCancellingAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isConnected || !client) {
|
if (!isConnected || !client) {
|
||||||
@@ -497,7 +472,7 @@ export function AgentInputArea({
|
|||||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
if (!voice || !isConnected || !agent) {
|
if (!voice || !isConnected || !hasAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (voice.isVoiceSwitching) {
|
if (voice.isVoiceSwitching) {
|
||||||
@@ -507,14 +482,14 @@ export function AgentInputArea({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
console.error("[Composer] Failed to start voice mode", error);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [agent, agentId, isConnected, serverId, toast, voice]);
|
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -606,7 +581,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<View style={styles.rightControls}>
|
<View style={styles.rightControls}>
|
||||||
{!isVoiceModeForAgent && agent ? (
|
{!isVoiceModeForAgent && hasAgent ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleToggleRealtimeVoice}
|
onPress={handleToggleRealtimeVoice}
|
||||||
@@ -704,6 +679,7 @@ export function AgentInputArea({
|
|||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
images={selectedImages}
|
images={selectedImages}
|
||||||
@@ -716,7 +692,7 @@ export function AgentInputArea({
|
|||||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isScreenFocused={isScreenFocused}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
@@ -17,6 +17,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
|||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
@@ -336,12 +337,13 @@ function SidebarContent({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const padding = useWindowControlsPadding("explorerSidebar");
|
||||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
{/* Header with tabs and close button */}
|
{/* Header with tabs and close button */}
|
||||||
<View style={styles.header} testID="explorer-header">
|
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||||
<View style={styles.tabsContainer}>
|
<View style={styles.tabsContainer}>
|
||||||
{isGit && (
|
{isGit && (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -26,18 +26,17 @@ import Animated, {
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
Image as ImageIcon,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|||||||
{ value: "size", label: "Size" },
|
{ value: "size", label: "Size" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const INDENT_PER_LEVEL = 12;
|
const INDENT_PER_LEVEL = 16;
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
@@ -384,7 +383,6 @@ export function FileExplorerPane({
|
|||||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||||
const entry = item.entry;
|
const entry = item.entry;
|
||||||
const depth = item.depth;
|
const depth = item.depth;
|
||||||
const displayKind = getEntryDisplayKind(entry);
|
|
||||||
const isDirectory = entry.kind === "directory";
|
const isDirectory = entry.kind === "directory";
|
||||||
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
||||||
const isSelected = selectedEntryPath === entry.path;
|
const isSelected = selectedEntryPath === entry.path;
|
||||||
@@ -399,16 +397,30 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed || isSelected) && styles.entryRowActive,
|
(hovered || pressed || isSelected) && styles.entryRowActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{depth > 0 &&
|
||||||
|
Array.from({ length: depth }, (_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.indentGuide,
|
||||||
|
{
|
||||||
|
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{loading ? (
|
{isDirectory ? (
|
||||||
<ActivityIndicator size="small" />
|
loading ? (
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
|
||||||
|
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
renderEntryIcon(isDirectory ? "directory" : displayKind, {
|
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
|
||||||
foreground: theme.colors.foregroundMuted,
|
|
||||||
primary: theme.colors.primary,
|
|
||||||
directoryOpen: isExpanded,
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.entryName} numberOfLines={1}>
|
<Text style={styles.entryName} numberOfLines={1}>
|
||||||
@@ -552,27 +564,31 @@ export function FileExplorerPane({
|
|||||||
) : (
|
) : (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<View style={styles.paneHeader} testID="files-pane-header">
|
||||||
<View style={styles.paneHeaderLeft} />
|
<Pressable
|
||||||
<View style={styles.paneHeaderRight}>
|
onPress={handleSortCycle}
|
||||||
<Pressable
|
style={({ hovered, pressed }) => [
|
||||||
onPress={handleRefresh}
|
styles.sortTrigger,
|
||||||
disabled={isRefreshFetching}
|
(hovered || pressed) && styles.sortTriggerHovered,
|
||||||
hitSlop={8}
|
]}
|
||||||
style={({ hovered, pressed }) => [
|
>
|
||||||
styles.iconButton,
|
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||||
(hovered || pressed) && styles.iconButtonHovered,
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
]}
|
</Pressable>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
accessibilityLabel="Refresh files"
|
onPress={handleRefresh}
|
||||||
>
|
disabled={isRefreshFetching}
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
hitSlop={8}
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
style={({ hovered, pressed }) => [
|
||||||
</Animated.View>
|
styles.iconButton,
|
||||||
</Pressable>
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
]}
|
||||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
accessibilityRole="button"
|
||||||
</Pressable>
|
accessibilityLabel="Refresh files"
|
||||||
</View>
|
>
|
||||||
|
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={treeListRef}
|
ref={treeListRef}
|
||||||
@@ -609,99 +625,6 @@ export function FileExplorerPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
|
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set([
|
|
||||||
"txt",
|
|
||||||
"md",
|
|
||||||
"markdown",
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"json",
|
|
||||||
"yml",
|
|
||||||
"yaml",
|
|
||||||
"toml",
|
|
||||||
"py",
|
|
||||||
"rb",
|
|
||||||
"go",
|
|
||||||
"rs",
|
|
||||||
"java",
|
|
||||||
"kt",
|
|
||||||
"c",
|
|
||||||
"cpp",
|
|
||||||
"cc",
|
|
||||||
"h",
|
|
||||||
"hpp",
|
|
||||||
"cs",
|
|
||||||
"swift",
|
|
||||||
"php",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"less",
|
|
||||||
"xml",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function renderEntryIcon(
|
|
||||||
kind: EntryDisplayKind,
|
|
||||||
colors: { foreground: string; primary: string; directoryOpen?: boolean },
|
|
||||||
) {
|
|
||||||
const color = colors.foreground;
|
|
||||||
switch (kind) {
|
|
||||||
case "directory":
|
|
||||||
return colors.directoryOpen ? (
|
|
||||||
<FolderOpen size={18} color={colors.primary} />
|
|
||||||
) : (
|
|
||||||
<Folder size={18} color={colors.primary} />
|
|
||||||
);
|
|
||||||
case "image":
|
|
||||||
return <ImageIcon size={18} color={color} />;
|
|
||||||
case "text":
|
|
||||||
return <FileText size={18} color={color} />;
|
|
||||||
default:
|
|
||||||
return <File size={18} color={color} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
|
|
||||||
if (entry.kind === "directory") {
|
|
||||||
return "directory";
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = getExtension(entry.name);
|
|
||||||
if (extension === null) {
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TEXT_EXTENSIONS.has(extension)) {
|
|
||||||
return "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExtension(name: string): string | null {
|
|
||||||
const index = name.lastIndexOf(".");
|
|
||||||
if (index === -1 || index === name.length - 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return name.slice(index + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||||
const sorted = [...entries];
|
const sorted = [...entries];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
paneHeader: {
|
paneHeader: {
|
||||||
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
paddingRight: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
},
|
||||||
paneHeaderLeft: {
|
sortTrigger: {
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
paneHeaderRight: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
previewHeaderRight: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
sortButton: {
|
|
||||||
height: 28,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.md,
|
marginLeft: theme.spacing[3] - theme.spacing[1],
|
||||||
borderWidth: theme.borderWidth[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
borderColor: theme.colors.border,
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
},
|
},
|
||||||
sortButtonText: {
|
sortTriggerHovered: {
|
||||||
color: theme.colors.foregroundMuted,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
sortTriggerText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
treeList: {
|
treeList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
entriesContent: {
|
entriesContent: {
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
centerState: {
|
centerState: {
|
||||||
@@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
indentGuide: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 1,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
entryInfo: {
|
entryInfo: {
|
||||||
@@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
chevron: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chevronExpanded: {
|
||||||
|
transform: [{ rotate: "90deg" }],
|
||||||
|
},
|
||||||
entryIcon: {
|
entryIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
|||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
|
borderless?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarMenuToggleProps {
|
interface SidebarMenuToggleProps {
|
||||||
@@ -75,7 +76,7 @@ export function SidebarMenuToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
@@ -90,6 +91,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
leftStyle={styles.left}
|
leftStyle={styles.left}
|
||||||
|
borderless={borderless}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,33 +6,29 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
getIsDesktopMac,
|
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
leftStyle?: StyleProp<ViewStyle>;
|
leftStyle?: StyleProp<ViewStyle>;
|
||||||
rightStyle?: StyleProp<ViewStyle>;
|
rightStyle?: StyleProp<ViewStyle>;
|
||||||
|
borderless?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* and safe-area logic in one place.
|
||||||
*/
|
*/
|
||||||
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
|
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const padding = useWindowControlsPadding("header");
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
const collapsedSidebarTrafficLightInset =
|
|
||||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
|
|
||||||
|
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
|
|
||||||
@@ -42,7 +38,11 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
{
|
||||||
|
paddingLeft: baseHorizontalPadding + padding.left,
|
||||||
|
paddingRight: baseHorizontalPadding + padding.right,
|
||||||
|
},
|
||||||
|
borderless && styles.borderless,
|
||||||
]}
|
]}
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
@@ -83,4 +83,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
borderless: {
|
||||||
|
borderBottomColor: "transparent",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface AiderIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
|
<Path
|
||||||
|
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface AmpIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
fill={color}
|
||||||
|
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill={color}
|
||||||
|
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill={color}
|
||||||
|
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill={color}
|
||||||
|
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface GeminiIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
fill={color}
|
||||||
|
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface OpenCodeIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||||
|
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||||
|
<Path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
@@ -520,6 +520,7 @@ function MobileSidebar({
|
|||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={closeToAgent}
|
onWorkspacePress={closeToAgent}
|
||||||
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -556,7 +557,7 @@ function MobileSidebar({
|
|||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Plus
|
<Plus
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.md}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -581,7 +582,7 @@ function MobileSidebar({
|
|||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Settings
|
<Settings
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.md}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -631,7 +632,7 @@ function DesktopSidebar({
|
|||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
@@ -681,9 +682,7 @@ function DesktopSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||||
{trafficLightPadding.top > 0 ? (
|
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
|
||||||
) : null}
|
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<SessionsButton onPress={handleViewMore} />
|
<SessionsButton onPress={handleViewMore} />
|
||||||
@@ -701,6 +700,7 @@ function DesktopSidebar({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
onAddProject={handleOpenProject}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -736,7 +736,7 @@ function DesktopSidebar({
|
|||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Plus
|
<Plus
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.md}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -761,7 +761,7 @@ function DesktopSidebar({
|
|||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Settings
|
<Settings
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.md}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -809,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Auto-generated from material-icon-theme. Do not edit manually.
|
||||||
|
|
||||||
|
const SVG_ICONS: Record<string, string> = {
|
||||||
|
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
|
||||||
|
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
|
||||||
|
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
|
||||||
|
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
|
||||||
|
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
|
||||||
|
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||||
|
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||||
|
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
|
||||||
|
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
|
||||||
|
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
|
||||||
|
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
|
||||||
|
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
|
||||||
|
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
|
||||||
|
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
|
||||||
|
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
|
||||||
|
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
|
||||||
|
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
|
||||||
|
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||||
|
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
|
||||||
|
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
|
||||||
|
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||||
|
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
|
||||||
|
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
|
||||||
|
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
|
||||||
|
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||||
|
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
|
||||||
|
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
|
||||||
|
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
|
||||||
|
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
|
||||||
|
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
|
||||||
|
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
|
||||||
|
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
|
||||||
|
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
|
||||||
|
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
|
||||||
|
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
|
||||||
|
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
|
||||||
|
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||||
|
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||||
|
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
|
||||||
|
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
|
||||||
|
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
|
||||||
|
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
|
||||||
|
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
|
||||||
|
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
|
||||||
|
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
|
||||||
|
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
|
||||||
|
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
|
||||||
|
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
|
||||||
|
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||||
|
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
|
||||||
|
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
|
||||||
|
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
|
||||||
|
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
|
||||||
|
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXTENSION_TO_ICON: Record<string, string> = {
|
||||||
|
"astro": "astro",
|
||||||
|
"bash": "console",
|
||||||
|
"c": "c",
|
||||||
|
"cfg": "settings",
|
||||||
|
"clj": "clojure",
|
||||||
|
"conf": "settings",
|
||||||
|
"cpp": "cpp",
|
||||||
|
"cs": "csharp",
|
||||||
|
"css": "css",
|
||||||
|
"dart": "dart",
|
||||||
|
"erl": "erlang",
|
||||||
|
"ex": "elixir",
|
||||||
|
"exs": "elixir",
|
||||||
|
"gif": "image",
|
||||||
|
"go": "go",
|
||||||
|
"gql": "graphql",
|
||||||
|
"gradle": "gradle",
|
||||||
|
"graphql": "graphql",
|
||||||
|
"groovy": "groovy",
|
||||||
|
"h": "h",
|
||||||
|
"hcl": "hcl",
|
||||||
|
"hpp": "hpp",
|
||||||
|
"hs": "haskell",
|
||||||
|
"html": "html",
|
||||||
|
"ico": "image",
|
||||||
|
"ini": "settings",
|
||||||
|
"java": "java",
|
||||||
|
"jpeg": "image",
|
||||||
|
"jpg": "image",
|
||||||
|
"js": "javascript",
|
||||||
|
"json": "json",
|
||||||
|
"jsx": "react",
|
||||||
|
"kt": "kotlin",
|
||||||
|
"less": "less",
|
||||||
|
"lock": "lock",
|
||||||
|
"lua": "lua",
|
||||||
|
"markdown": "markdown",
|
||||||
|
"md": "markdown",
|
||||||
|
"ml": "ocaml",
|
||||||
|
"nix": "nix",
|
||||||
|
"php": "php",
|
||||||
|
"png": "image",
|
||||||
|
"py": "python",
|
||||||
|
"r": "r",
|
||||||
|
"rb": "ruby",
|
||||||
|
"rs": "rust",
|
||||||
|
"scala": "scala",
|
||||||
|
"scss": "sass",
|
||||||
|
"sh": "console",
|
||||||
|
"sql": "database",
|
||||||
|
"svelte": "svelte",
|
||||||
|
"svg": "svg",
|
||||||
|
"swift": "swift",
|
||||||
|
"tf": "terraform",
|
||||||
|
"toml": "toml",
|
||||||
|
"ts": "typescript",
|
||||||
|
"tsx": "react_ts",
|
||||||
|
"txt": "document",
|
||||||
|
"vue": "vue",
|
||||||
|
"wasm": "webassembly",
|
||||||
|
"webp": "image",
|
||||||
|
"xml": "xml",
|
||||||
|
"yaml": "yaml",
|
||||||
|
"yml": "yaml",
|
||||||
|
"zig": "zig",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFileIconSvg(fileName: string): string {
|
||||||
|
const ext = getExtension(fileName);
|
||||||
|
if (ext) {
|
||||||
|
const iconName = EXTENSION_TO_ICON[ext];
|
||||||
|
if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName];
|
||||||
|
}
|
||||||
|
return SVG_ICONS["_default"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtension(name: string): string | null {
|
||||||
|
const idx = name.lastIndexOf(".");
|
||||||
|
if (idx === -1 || idx === name.length - 1) return null;
|
||||||
|
return name.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export interface MessageInputProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmit: (payload: MessagePayload) => void;
|
onSubmit: (payload: MessagePayload) => void;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
isSubmitDisabled?: boolean;
|
isSubmitDisabled?: boolean;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
images?: ImageAttachment[];
|
images?: ImageAttachment[];
|
||||||
@@ -66,8 +67,8 @@ export interface MessageInputProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoFocusKey?: string;
|
autoFocusKey?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||||
isScreenFocused?: boolean;
|
isInputActive?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||||
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
allowEmptySubmit = false,
|
||||||
isSubmitDisabled = false,
|
isSubmitDisabled = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
images = [],
|
images = [],
|
||||||
@@ -190,7 +192,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoFocusKey,
|
autoFocusKey,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isScreenFocused = true,
|
isInputActive = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
@@ -346,9 +348,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
[onChangeText, onSubmit, images, isAgentRunning],
|
[onChangeText, onSubmit, images, isAgentRunning],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback((error: Error) => {
|
const handleDictationError = useCallback(
|
||||||
console.error("[MessageInput] Dictation error:", error);
|
(error: Error) => {
|
||||||
}, []);
|
console.error("[MessageInput] Dictation error:", error);
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||||
serverInfo,
|
serverInfo,
|
||||||
@@ -387,7 +393,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
canStart: canStartDictation,
|
canStart: canStartDictation,
|
||||||
canConfirm: canConfirmDictation,
|
canConfirm: canConfirmDictation,
|
||||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
autoStopWhenHidden: { isVisible: isInputActive },
|
||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -847,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
|
||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
@@ -950,18 +956,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View style={styles.leftButtonGroup}>
|
<View style={styles.leftButtonGroup}>
|
||||||
{onPickImages && (
|
{onPickImages && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger asChild>
|
||||||
onPress={onPickImages}
|
<Pressable
|
||||||
disabled={!isConnected || disabled}
|
onPress={onPickImages}
|
||||||
accessibilityLabel="Attach images"
|
disabled={!isConnected || disabled}
|
||||||
accessibilityRole="button"
|
accessibilityLabel="Attach images"
|
||||||
style={({ hovered }) => [
|
accessibilityRole="button"
|
||||||
styles.attachButton,
|
style={({ hovered }) => [
|
||||||
hovered && styles.iconButtonHovered,
|
styles.attachButton,
|
||||||
(!isConnected || disabled) && styles.buttonDisabled,
|
hovered && styles.iconButtonHovered,
|
||||||
]}
|
(!isConnected || disabled) && styles.buttonDisabled,
|
||||||
>
|
]}
|
||||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
>
|
||||||
|
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Attach images</Text>
|
<Text style={styles.tooltipText}>Attach images</Text>
|
||||||
@@ -1203,7 +1211,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
leftButtonGroup: {
|
leftButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
rightButtonGroup: {
|
rightButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<Text
|
<Text
|
||||||
key={node.key}
|
key={node.key}
|
||||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||||
selectable={false}
|
selectable={Platform.OS === "web" ? undefined : false}
|
||||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Bot } from "lucide-react-native";
|
import { Bot } from "lucide-react-native";
|
||||||
|
import { AiderIcon } from "@/components/icons/aider-icon";
|
||||||
|
import { AmpIcon } from "@/components/icons/amp-icon";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
|
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||||
|
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
|
gemini: GeminiIcon as unknown as typeof Bot,
|
||||||
|
amp: AmpIcon as unknown as typeof Bot,
|
||||||
|
aider: AiderIcon as unknown as typeof Bot,
|
||||||
|
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProviderIcon(provider: string): typeof Bot {
|
export function getProviderIcon(provider: string): typeof Bot {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
import { useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FolderPlus,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -44,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
|
|||||||
import { getIsDesktop } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
@@ -73,6 +73,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
|||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
@@ -81,8 +82,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ interface SidebarWorkspaceListProps {
|
|||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
|
onAddProject?: () => void;
|
||||||
listFooterComponent?: ReactElement | null;
|
listFooterComponent?: ReactElement | null;
|
||||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
@@ -236,7 +238,7 @@ function WorkspaceStatusIndicator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KindIcon =
|
const KindIcon =
|
||||||
workspaceKind === "local_checkout"
|
workspaceKind === "checkout"
|
||||||
? Monitor
|
? Monitor
|
||||||
: workspaceKind === "worktree"
|
: workspaceKind === "worktree"
|
||||||
? FolderGit2
|
? FolderGit2
|
||||||
@@ -392,15 +394,15 @@ function NewWorktreeButton({
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) =>
|
{({ hovered, pressed }) =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
<Plus
|
<FolderPlus
|
||||||
size={14}
|
size={15}
|
||||||
color={
|
color={
|
||||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
}
|
}
|
||||||
@@ -411,7 +413,7 @@ function NewWorktreeButton({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.projectActionTooltipRow}>
|
<View style={styles.projectActionTooltipRow}>
|
||||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||||
{showShortcutHint && newWorktreeKeys ? (
|
{showShortcutHint && newWorktreeKeys ? (
|
||||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -651,7 +653,6 @@ function ProjectHeaderRow({
|
|||||||
canCreateWorktree,
|
canCreateWorktree,
|
||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorktreeCreated,
|
|
||||||
shortcutNumber = null,
|
shortcutNumber = null,
|
||||||
showShortcutBadge = false,
|
showShortcutBadge = false,
|
||||||
drag,
|
drag,
|
||||||
@@ -663,50 +664,30 @@ function ProjectHeaderRow({
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint =
|
const isMobileBreakpoint =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
|
||||||
const toast = useToast();
|
|
||||||
|
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||||
|
if (!serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onWorkspacePress?.();
|
||||||
|
beginWorkspaceSetup({
|
||||||
|
serverId,
|
||||||
|
projectPath: project.iconWorkingDir,
|
||||||
|
projectName: displayName,
|
||||||
|
creationMethod: "create_worktree",
|
||||||
|
navigationMethod: "navigate",
|
||||||
|
});
|
||||||
|
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||||
|
|
||||||
const createWorktreeMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("No server");
|
|
||||||
}
|
|
||||||
const client = getHostRuntimeStore().getClient(serverId);
|
|
||||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
|
||||||
throw new Error("Host is not connected");
|
|
||||||
}
|
|
||||||
const payload = await client.createPaseoWorktree({
|
|
||||||
cwd: project.iconWorkingDir,
|
|
||||||
worktreeSlug: createNameId(),
|
|
||||||
});
|
|
||||||
if (payload.error || !payload.workspace) {
|
|
||||||
throw new Error(payload.error ?? "Failed to create worktree");
|
|
||||||
}
|
|
||||||
return payload.workspace;
|
|
||||||
},
|
|
||||||
onSuccess: (workspace) => {
|
|
||||||
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
|
|
||||||
onWorktreeCreated?.(workspace.id);
|
|
||||||
onWorkspacePress?.();
|
|
||||||
router.navigate(
|
|
||||||
prepareWorkspaceTab({
|
|
||||||
serverId: serverId!,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
target: { kind: "draft", draftId: "new" },
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-new-${project.projectKey}`,
|
handlerId: `worktree-new-${project.projectKey}`,
|
||||||
actions: ["worktree.new"],
|
actions: ["worktree.new"],
|
||||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
enabled: isProjectActive && canCreateWorktree,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle: () => {
|
handle: () => {
|
||||||
createWorktreeMutation.mutate();
|
handleBeginWorkspaceSetup();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -750,9 +731,9 @@ function ProjectHeaderRow({
|
|||||||
{canCreateWorktree ? (
|
{canCreateWorktree ? (
|
||||||
<NewWorktreeButton
|
<NewWorktreeButton
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onPress={() => createWorktreeMutation.mutate()}
|
onPress={handleBeginWorkspaceSetup}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
visible={isHovered || isMobileBreakpoint}
|
||||||
loading={createWorktreeMutation.isPending}
|
loading={false}
|
||||||
showShortcutHint={isProjectActive}
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
/>
|
/>
|
||||||
@@ -837,7 +818,7 @@ function WorkspaceRowInner({
|
|||||||
const prHint = useWorkspacePrHint({
|
const prHint = useWorkspacePrHint({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspace.workspaceId,
|
||||||
enabled: workspace.workspaceKind !== "directory",
|
enabled: workspace.projectKind === "git",
|
||||||
});
|
});
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
@@ -1093,7 +1074,7 @@ function WorkspaceRowWithMenu({
|
|||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1219,7 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1330,7 @@ function FlattenedProjectRow({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
isProjectActive?: boolean;
|
isProjectActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (project.projectKind === "non_git") {
|
if (project.projectKind === "directory") {
|
||||||
return (
|
return (
|
||||||
<NonGitProjectRowWithMenu
|
<NonGitProjectRowWithMenu
|
||||||
project={project}
|
project={project}
|
||||||
@@ -1631,6 +1612,7 @@ export function SidebarWorkspaceList({
|
|||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
|
onAddProject,
|
||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
@@ -1900,7 +1882,18 @@ export function SidebarWorkspaceList({
|
|||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Text style={styles.emptyText}>No projects yet</Text>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<DraggableList
|
<DraggableList
|
||||||
testID="sidebar-project-list"
|
testID="sidebar-project-list"
|
||||||
@@ -1963,11 +1956,27 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
workspaceListContainer: {},
|
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: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: theme.spacing[8],
|
|
||||||
marginHorizontal: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
projectRow: {
|
projectRow: {
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { ResizeHandle } from "@/components/resize-handle";
|
import { ResizeHandle } from "@/components/resize-handle";
|
||||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useTrafficLightPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import {
|
import {
|
||||||
computeTabDropPreview,
|
computeTabDropPreview,
|
||||||
type TabDropPreview,
|
type TabDropPreview,
|
||||||
@@ -86,12 +86,7 @@ interface SplitContainerProps {
|
|||||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (selection: {
|
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||||
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
|
||||||
paneId?: string;
|
|
||||||
}) => void;
|
|
||||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
|
||||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
@@ -267,9 +262,7 @@ export function SplitContainer({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId = "__new_tab_agent__",
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -536,9 +529,7 @@ export function SplitContainer({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -658,9 +649,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId,
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -693,9 +682,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -743,9 +730,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -792,9 +777,7 @@ function SplitPaneView({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId,
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -809,8 +792,7 @@ function SplitPaneView({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const paneRef = useRef<View | null>(null);
|
const paneRef = useRef<View | null>(null);
|
||||||
const stableOnFocusPane = useStableEvent(onFocusPane);
|
const stableOnFocusPane = useStableEvent(onFocusPane);
|
||||||
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
|
const padding = useWindowControlsPadding("tabRow");
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
|
||||||
const paneState = useMemo(
|
const paneState = useMemo(
|
||||||
() =>
|
() =>
|
||||||
deriveWorkspacePaneState({
|
deriveWorkspacePaneState({
|
||||||
@@ -885,8 +867,7 @@ function SplitPaneView({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.paneTabs,
|
styles.paneTabs,
|
||||||
isFocusModeEnabled &&
|
{ paddingLeft: padding.left, paddingRight: padding.right },
|
||||||
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
@@ -904,9 +885,7 @@ function SplitPaneView({
|
|||||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
|
||||||
onReorderTabs={(nextTabs) => {
|
onReorderTabs={(nextTabs) => {
|
||||||
onReorderTabsInPane(
|
onReorderTabsInPane(
|
||||||
pane.id,
|
pane.id,
|
||||||
|
|||||||
@@ -565,6 +565,18 @@ export default function TerminalEmulator({
|
|||||||
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
|
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
|
||||||
const handleTravelDurationMs =
|
const handleTravelDurationMs =
|
||||||
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -586,6 +598,10 @@ export default function TerminalEmulator({
|
|||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
runtimeRef.current?.focus();
|
runtimeRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleContextMenu();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
|
|||||||
@@ -150,70 +150,136 @@ export function Autocomplete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedOption = options[selectedIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { maxHeight }]}>
|
<View style={styles.outerWrapper}>
|
||||||
<ScrollView
|
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||||
ref={scrollRef}
|
<View style={styles.detailCard}>
|
||||||
onLayout={handleScrollViewLayout}
|
<Text style={styles.detailLabel}>
|
||||||
onContentSizeChange={pinToBottom}
|
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||||
onScroll={(event) => {
|
</Text>
|
||||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
<Text style={styles.detailDescription}>
|
||||||
}}
|
{removeBoltGlyphs(selectedOption.description)}
|
||||||
scrollEventThrottle={16}
|
</Text>
|
||||||
style={styles.scrollView}
|
{selectedOption.detail ? (
|
||||||
contentContainerStyle={styles.scrollContent}
|
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
||||||
keyboardShouldPersistTaps="always"
|
) : null}
|
||||||
>
|
</View>
|
||||||
{options.map((option, index) => {
|
) : null}
|
||||||
const isSelected = index === selectedIndex;
|
<View style={[styles.container, { maxHeight }]}>
|
||||||
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
<ScrollView
|
||||||
const optionDetail = removeBoltGlyphs(option.detail);
|
ref={scrollRef}
|
||||||
const optionDescription = removeBoltGlyphs(option.description);
|
onLayout={handleScrollViewLayout}
|
||||||
return (
|
onContentSizeChange={pinToBottom}
|
||||||
<Pressable
|
onScroll={(event) => {
|
||||||
key={option.id}
|
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||||
onLayout={(event) => handleRowLayout(index, event)}
|
}}
|
||||||
onPress={() => onSelect(option)}
|
scrollEventThrottle={16}
|
||||||
style={({ hovered = false, pressed }) => [
|
style={styles.scrollView}
|
||||||
styles.item,
|
contentContainerStyle={styles.scrollContent}
|
||||||
(hovered || pressed || isSelected) && styles.itemActive,
|
keyboardShouldPersistTaps="always"
|
||||||
]}
|
>
|
||||||
>
|
{options.map((option, index) => {
|
||||||
{option.kind === "directory" || option.kind === "file" ? (
|
const isSelected = index === selectedIndex;
|
||||||
<View style={styles.itemLeading}>
|
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
||||||
{option.kind === "directory" ? (
|
const optionDescription = removeBoltGlyphs(option.description);
|
||||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
||||||
) : (
|
return (
|
||||||
<File size={14} color={theme.colors.foregroundMuted} />
|
<Pressable
|
||||||
)}
|
key={option.id}
|
||||||
</View>
|
onLayout={(event) => handleRowLayout(index, event)}
|
||||||
) : null}
|
onPress={() => onSelect(option)}
|
||||||
<View style={styles.itemMain}>
|
style={({ hovered = false, pressed }) => [
|
||||||
<View style={styles.itemHeader}>
|
styles.item,
|
||||||
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
(hovered || pressed || isSelected) && styles.itemActive,
|
||||||
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
|
]}
|
||||||
</View>
|
>
|
||||||
{optionDescription ? (
|
{isFileOrDir ? (
|
||||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
<>
|
||||||
{optionDescription}
|
<View style={styles.itemLeading}>
|
||||||
</Text>
|
{option.kind === "directory" ? (
|
||||||
) : null}
|
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
) : (
|
||||||
</Pressable>
|
<File size={14} color={theme.colors.foregroundMuted} />
|
||||||
);
|
)}
|
||||||
})}
|
</View>
|
||||||
</ScrollView>
|
<View style={styles.itemMain}>
|
||||||
|
<View style={styles.itemHeader}>
|
||||||
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||||
|
{removeBoltGlyphs(option.detail) ? (
|
||||||
|
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{optionDescription ? (
|
||||||
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||||
|
{optionDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.itemMainRow}>
|
||||||
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||||
|
{optionDescription ? (
|
||||||
|
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
||||||
|
{optionDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||||
container: {
|
outerWrapper: {
|
||||||
backgroundColor: theme.colors.surface0,
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailCard: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderAccent,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
detailDescription: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailHint: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
marginRight: theme.spacing[1],
|
marginRight: theme.spacing[1],
|
||||||
},
|
},
|
||||||
itemActive: {
|
itemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemMain: {
|
itemMain: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
itemMainRow: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
itemHeader: {
|
itemHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
itemDescriptionInline: {
|
||||||
|
flex: 1,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
emptyItem: {
|
emptyItem: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
|
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||||
type ButtonSize = "sm" | "md" | "lg";
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
base: {
|
base: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -67,6 +75,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
textDestructive: {
|
textDestructive: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.palette.white,
|
||||||
},
|
},
|
||||||
|
textGhost: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
textGhostHovered: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
@@ -83,11 +97,14 @@ export function Button({
|
|||||||
Omit<PressableProps, "style"> & {
|
Omit<PressableProps, "style"> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
leftIcon?: ReactElement | null;
|
leftIcon?: LeftIcon;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const variantStyle =
|
const variantStyle =
|
||||||
variant === "default"
|
variant === "default"
|
||||||
? styles.default
|
? styles.default
|
||||||
@@ -100,19 +117,47 @@ export function Button({
|
|||||||
: styles.destructive;
|
: styles.destructive;
|
||||||
|
|
||||||
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
|
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
|
||||||
|
const isGhostHovered = hovered && variant === "ghost";
|
||||||
|
|
||||||
const resolvedTextStyle = [
|
const resolvedTextStyle = [
|
||||||
styles.text,
|
styles.text,
|
||||||
variant === "default" ? styles.textDefault : null,
|
variant === "default" ? styles.textDefault : null,
|
||||||
variant === "destructive" ? styles.textDestructive : null,
|
variant === "destructive" ? styles.textDestructive : null,
|
||||||
|
variant === "ghost" ? styles.textGhost : null,
|
||||||
textStyle,
|
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 (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...props}
|
{...props}
|
||||||
accessibilityRole={accessibilityRole ?? "button"}
|
accessibilityRole={accessibilityRole ?? "button"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onHoverIn={() => setHovered(true)}
|
||||||
|
onHoverOut={() => setHovered(false)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.base,
|
styles.base,
|
||||||
sizeStyle,
|
sizeStyle,
|
||||||
@@ -122,7 +167,7 @@ export function Button({
|
|||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{leftIcon ? <View>{leftIcon}</View> : null}
|
{renderIcon()}
|
||||||
<Text style={resolvedTextStyle}>{children}</Text>
|
<Text style={resolvedTextStyle}>{children}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -278,7 +278,10 @@ export function DropdownMenuContent({
|
|||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
} else if (modalVisible) {
|
} else if (modalVisible) {
|
||||||
setClosing(true);
|
// Avoid leaving an invisible full-screen Modal mounted on native when
|
||||||
|
// the exit animation callback does not fire.
|
||||||
|
setClosing(false);
|
||||||
|
setModalVisible(false);
|
||||||
}
|
}
|
||||||
}, [open, modalVisible]);
|
}, [open, modalVisible]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHostMutations } from "@/runtime/host-runtime";
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHostMutations,
|
||||||
|
useHostRuntimeSnapshot,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
actionTextPrimary: {
|
actionTextPrimary: {
|
||||||
color: theme.colors.accentForeground,
|
color: theme.colors.accentForeground,
|
||||||
},
|
},
|
||||||
|
hostList: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
marginTop: theme.spacing[6],
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
hostRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
hostLabel: {
|
||||||
|
flex: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatus: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
hostStatusError: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
versionLabel: {
|
versionLabel: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||||
|
const runtime = getHostRuntimeStore();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
() => {
|
||||||
|
let firstOnlineServerId: string | null = null;
|
||||||
|
let firstOnlineAt: string | null = null;
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
|
firstOnlineAt = lastOnlineAt;
|
||||||
|
firstOnlineServerId = serverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstOnlineServerId;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
let firstOnlineServerId: string | null = null;
|
||||||
|
let firstOnlineAt: string | null = null;
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
|
firstOnlineAt = lastOnlineAt;
|
||||||
|
firstOnlineServerId = serverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstOnlineServerId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const status = snapshot?.connectionStatus ?? "connecting";
|
||||||
|
const lastError = snapshot?.lastError ?? null;
|
||||||
|
|
||||||
|
let dotColor: string;
|
||||||
|
let statusText: string;
|
||||||
|
let isError = false;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
dotColor = theme.colors.success;
|
||||||
|
statusText = "Online";
|
||||||
|
break;
|
||||||
|
case "connecting":
|
||||||
|
case "idle":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Connecting…";
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
dotColor = theme.colors.foregroundMuted;
|
||||||
|
statusText = "Offline";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
dotColor = theme.colors.destructive;
|
||||||
|
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
|
||||||
|
isError = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.hostRow}>
|
||||||
|
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
|
||||||
|
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WelcomeScreenProps {
|
export interface WelcomeScreenProps {
|
||||||
onHostAdded?: (profile: HostProfile) => void;
|
onHostAdded?: (profile: HostProfile) => void;
|
||||||
}
|
}
|
||||||
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||||
|
const hosts = useHosts();
|
||||||
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
const pendingNameHostname = useSessionStore(
|
const pendingNameHostname = useSessionStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anyOnlineServerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingNameHost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||||
|
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||||
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
<Text style={styles.subtitle}>
|
||||||
|
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{showHostList && (
|
||||||
|
<View style={styles.hostList}>
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import { createNameId } from "mnemonic-id";
|
||||||
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { Composer } from "@/components/composer";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||||
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import type { MessagePayload } from "./message-input";
|
||||||
|
|
||||||
|
type SetupStep = "choose" | "chat" | "terminal-agent";
|
||||||
|
|
||||||
|
export function WorkspaceSetupDialog() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
|
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||||
|
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||||
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
|
const [step, setStep] = useState<SetupStep>("choose");
|
||||||
|
const [terminalPrompt, setTerminalPrompt] = useState("");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||||
|
typeof normalizeWorkspaceDescriptor
|
||||||
|
> | null>(null);
|
||||||
|
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||||
|
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
|
||||||
|
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
|
||||||
|
const workspace = createdWorkspace;
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const chatDraft = useAgentInputDraft({
|
||||||
|
draftKey: `workspace-setup:${serverId}:${projectPath}`,
|
||||||
|
composer: {
|
||||||
|
initialServerId: serverId || null,
|
||||||
|
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||||
|
isVisible: pendingWorkspaceSetup !== null,
|
||||||
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
|
lockedWorkingDir: workspace?.id ?? projectPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const composerState = chatDraft.composerState;
|
||||||
|
if (!composerState && pendingWorkspaceSetup) {
|
||||||
|
throw new Error("Workspace setup composer state is required");
|
||||||
|
}
|
||||||
|
const { providers: sortedProviders, recordUsage } = useProviderRecency(
|
||||||
|
composerState?.providerDefinitions ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStep("choose");
|
||||||
|
setTerminalPrompt("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setCreatedWorkspace(null);
|
||||||
|
setPendingAction(null);
|
||||||
|
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
clearWorkspaceSetup();
|
||||||
|
}, [clearWorkspaceSetup]);
|
||||||
|
|
||||||
|
const navigateAfterCreation = useCallback(
|
||||||
|
(
|
||||||
|
workspaceId: string,
|
||||||
|
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
|
||||||
|
) => {
|
||||||
|
if (!pendingWorkspaceSetup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWorkspaceSetup();
|
||||||
|
navigateToPreparedWorkspaceTab({
|
||||||
|
serverId: pendingWorkspaceSetup.serverId,
|
||||||
|
workspaceId,
|
||||||
|
target,
|
||||||
|
navigationMethod: pendingWorkspaceSetup.navigationMethod,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const withConnectedClient = useCallback(() => {
|
||||||
|
if (!client || !isConnected) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}, [client, isConnected]);
|
||||||
|
|
||||||
|
const ensureWorkspace = useCallback(async () => {
|
||||||
|
if (!pendingWorkspaceSetup) {
|
||||||
|
throw new Error("No workspace setup is pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdWorkspace) {
|
||||||
|
return createdWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedClient = withConnectedClient();
|
||||||
|
const payload =
|
||||||
|
pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||||
|
? await connectedClient.createPaseoWorktree({
|
||||||
|
cwd: pendingWorkspaceSetup.projectPath,
|
||||||
|
worktreeSlug: createNameId(),
|
||||||
|
})
|
||||||
|
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
|
||||||
|
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error ??
|
||||||
|
(pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||||
|
? "Failed to create worktree"
|
||||||
|
: "Failed to open project"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||||
|
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
|
||||||
|
if (pendingWorkspaceSetup.creationMethod === "open_project") {
|
||||||
|
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
|
||||||
|
}
|
||||||
|
setCreatedWorkspace(normalizedWorkspace);
|
||||||
|
return normalizedWorkspace;
|
||||||
|
}, [
|
||||||
|
createdWorkspace,
|
||||||
|
mergeWorkspaces,
|
||||||
|
pendingWorkspaceSetup,
|
||||||
|
setHasHydratedWorkspaces,
|
||||||
|
withConnectedClient,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getIsStillActive = useCallback(() => {
|
||||||
|
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
|
||||||
|
return (
|
||||||
|
current?.serverId === pendingWorkspaceSetup?.serverId &&
|
||||||
|
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
|
||||||
|
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
pendingWorkspaceSetup?.creationMethod,
|
||||||
|
pendingWorkspaceSetup?.projectPath,
|
||||||
|
pendingWorkspaceSetup?.serverId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCreateChatAgent = useCallback(
|
||||||
|
async ({ text, images }: MessagePayload) => {
|
||||||
|
try {
|
||||||
|
setPendingAction("chat");
|
||||||
|
setErrorMessage(null);
|
||||||
|
const workspace = await ensureWorkspace();
|
||||||
|
const connectedClient = withConnectedClient();
|
||||||
|
if (!composerState) {
|
||||||
|
throw new Error("Workspace setup composer state is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedImages = await encodeImages(images);
|
||||||
|
const agent = await connectedClient.createAgent({
|
||||||
|
provider: composerState.selectedProvider,
|
||||||
|
cwd: workspace.id,
|
||||||
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
|
? { modeId: composerState.selectedMode }
|
||||||
|
: {}),
|
||||||
|
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||||
|
...(composerState.effectiveThinkingOptionId
|
||||||
|
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
...(text.trim() ? { initialPrompt: text.trim() } : {}),
|
||||||
|
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getIsStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAgents(serverId, (previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||||
|
} catch (error) {
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
if (getIsStillActive()) {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
composerState,
|
||||||
|
getIsStillActive,
|
||||||
|
navigateAfterCreation,
|
||||||
|
serverId,
|
||||||
|
setAgents,
|
||||||
|
ensureWorkspace,
|
||||||
|
toast,
|
||||||
|
withConnectedClient,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTerminalAgent = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPendingAction("terminal-agent");
|
||||||
|
setErrorMessage(null);
|
||||||
|
const workspace = await ensureWorkspace();
|
||||||
|
const connectedClient = withConnectedClient();
|
||||||
|
if (!composerState) {
|
||||||
|
throw new Error("Workspace setup composer state is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await connectedClient.createAgent({
|
||||||
|
provider: composerState.selectedProvider,
|
||||||
|
cwd: workspace.id,
|
||||||
|
terminal: true,
|
||||||
|
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getIsStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordUsage(composerState.selectedProvider);
|
||||||
|
setAgents(serverId, (previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||||
|
} catch (error) {
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
if (getIsStillActive()) {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
composerState,
|
||||||
|
getIsStillActive,
|
||||||
|
navigateAfterCreation,
|
||||||
|
recordUsage,
|
||||||
|
serverId,
|
||||||
|
setAgents,
|
||||||
|
ensureWorkspace,
|
||||||
|
terminalPrompt,
|
||||||
|
toast,
|
||||||
|
withConnectedClient,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCreateTerminal = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPendingAction("terminal");
|
||||||
|
setErrorMessage(null);
|
||||||
|
const workspace = await ensureWorkspace();
|
||||||
|
const connectedClient = withConnectedClient();
|
||||||
|
|
||||||
|
const payload = await connectedClient.createTerminal(workspace.id);
|
||||||
|
if (payload.error || !payload.terminal) {
|
||||||
|
throw new Error(payload.error ?? "Failed to open terminal");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getIsStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
|
||||||
|
} catch (error) {
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
if (getIsStillActive()) {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
|
||||||
|
|
||||||
|
const workspaceTitle =
|
||||||
|
workspace?.name ||
|
||||||
|
workspace?.projectDisplayName ||
|
||||||
|
projectName ||
|
||||||
|
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
|
||||||
|
projectPath;
|
||||||
|
const workspacePath = workspace?.projectRootPath || projectPath;
|
||||||
|
|
||||||
|
if (!pendingWorkspaceSetup || !projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
title="Set up workspace"
|
||||||
|
visible={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
snapPoints={["82%", "94%"]}
|
||||||
|
testID="workspace-setup-dialog"
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
|
||||||
|
<Text style={styles.workspacePath}>{workspacePath}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{step === "choose" ? (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>What do you want to open?</Text>
|
||||||
|
<View style={styles.choiceGrid}>
|
||||||
|
<ChoiceCard
|
||||||
|
title="Chat Agent"
|
||||||
|
description="Open this workspace with a prompt-first chat agent."
|
||||||
|
Icon={MessagesSquare}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
onPress={() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep("chat");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
title="Terminal Agent"
|
||||||
|
description="Launch an agent-backed terminal in an agent tab."
|
||||||
|
Icon={Bot}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
onPress={() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep("terminal-agent");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
title="Terminal"
|
||||||
|
description="Create the workspace, then open a standalone terminal tab."
|
||||||
|
Icon={SquareTerminal}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
pending={pendingAction === "terminal"}
|
||||||
|
onPress={() => {
|
||||||
|
void handleCreateTerminal();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === "chat" ? (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<StepHeader
|
||||||
|
title="Chat Agent"
|
||||||
|
onBack={() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep("choose");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={styles.helper}>
|
||||||
|
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.composerCard}>
|
||||||
|
<Composer
|
||||||
|
agentId={`workspace-setup:${serverId}:${projectPath}`}
|
||||||
|
serverId={serverId}
|
||||||
|
isInputActive={true}
|
||||||
|
onSubmitMessage={handleCreateChatAgent}
|
||||||
|
isSubmitLoading={pendingAction === "chat"}
|
||||||
|
blurOnSubmit={true}
|
||||||
|
value={chatDraft.text}
|
||||||
|
onChangeText={chatDraft.setText}
|
||||||
|
images={chatDraft.images}
|
||||||
|
onChangeImages={chatDraft.setImages}
|
||||||
|
clearDraft={chatDraft.clear}
|
||||||
|
autoFocus
|
||||||
|
commandDraftConfig={composerState?.commandDraftConfig}
|
||||||
|
statusControls={
|
||||||
|
composerState
|
||||||
|
? {
|
||||||
|
...composerState.statusControls,
|
||||||
|
disabled: pendingAction !== null,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === "terminal-agent" ? (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<StepHeader
|
||||||
|
title="Terminal Agent"
|
||||||
|
onBack={() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep("choose");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={styles.helper}>
|
||||||
|
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.providerGrid}>
|
||||||
|
{sortedProviders.map((provider) => (
|
||||||
|
<ProviderOption
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
selected={provider.id === composerState?.selectedProvider}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
onPress={() => composerState?.setProviderFromUser(provider.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.fieldLabel}>Initial prompt</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
value={terminalPrompt}
|
||||||
|
onChangeText={setTerminalPrompt}
|
||||||
|
placeholder="Optional"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
multiline
|
||||||
|
autoCapitalize="sentences"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
style={styles.actionButton}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
onPress={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
style={styles.actionButton}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
onPress={() => {
|
||||||
|
void handleCreateTerminalAgent();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stepHeader}>
|
||||||
|
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||||
|
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
Icon,
|
||||||
|
disabled,
|
||||||
|
pending = false,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
Icon: ComponentType<{ size: number; color: string }>;
|
||||||
|
disabled: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.choiceCard,
|
||||||
|
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||||
|
disabled ? styles.cardDisabled : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.choiceIconWrap}>
|
||||||
|
{pending ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
|
) : (
|
||||||
|
<Icon size={16} color={theme.colors.foreground} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.choiceBody}>
|
||||||
|
<Text style={styles.choiceTitle}>{title}</Text>
|
||||||
|
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderOption({
|
||||||
|
provider,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
provider: { id: AgentProvider; label: string; description: string };
|
||||||
|
selected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const Icon = getProviderIcon(provider.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.providerCard,
|
||||||
|
selected ? styles.providerCardSelected : null,
|
||||||
|
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||||
|
disabled ? styles.cardDisabled : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<Icon size={16} color={theme.colors.foreground} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.providerBody}>
|
||||||
|
<Text style={styles.providerTitle}>{provider.label}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
header: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
workspaceTitle: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
workspacePath: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
choiceGrid: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
choiceCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
choiceCardHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
cardDisabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
choiceIconWrap: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
choiceBody: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
choiceTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
choiceDescription: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
composerCard: {
|
||||||
|
minHeight: 180,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
stepHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
providerCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
providerCardSelected: {
|
||||||
|
borderColor: theme.colors.accent,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerBody: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
providerTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 80,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
textAlignVertical: "top",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -19,6 +19,10 @@ export const MAX_CONTENT_WIDTH = 820;
|
|||||||
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||||
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||||
|
|
||||||
|
// Windows/Linux window controls (minimize/maximize/close) — top-right
|
||||||
|
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||||
|
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||||
|
|
||||||
// Check if running in desktop app (any OS)
|
// Check if running in desktop app (any OS)
|
||||||
function isDesktopEnvironment(): boolean {
|
function isDesktopEnvironment(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
@@ -56,14 +60,3 @@ export function getIsDesktop(): boolean {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traffic light padding values (only non-zero on desktop macOS)
|
|
||||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
|
||||||
if (!getIsDesktopMac()) {
|
|
||||||
return { left: 0, top: 0 };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
|
||||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getRightSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||||
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
|
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
return;
|
nextIsOpen: isOpen,
|
||||||
}
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
|||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionAgents) {
|
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||||
previousAgentStatusRef.current.clear();
|
previousAgentStatusRef.current,
|
||||||
return;
|
sessionAgents,
|
||||||
}
|
);
|
||||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
|
||||||
for (const nextAgent of sessionAgents.values()) {
|
|
||||||
nextStatuses.set(nextAgent.id, nextAgent.status);
|
|
||||||
}
|
|
||||||
previousAgentStatusRef.current = nextStatuses;
|
|
||||||
}, [sessionAgents]);
|
}, [sessionAgents]);
|
||||||
|
|
||||||
const hydrateWorkspaces = useCallback(
|
const hydrateWorkspaces = useCallback(
|
||||||
@@ -525,9 +521,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
void client
|
void client
|
||||||
.fetchAgentTimeline(agentId, {
|
.fetchAgentTimeline(agentId, {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
cursor: { seq: cursor.endSeq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||||
@@ -753,13 +748,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestCanonicalCatchUp = useCallback(
|
const requestCanonicalCatchUp = useCallback(
|
||||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
(agentId: string, cursor: { endSeq: number }) => {
|
||||||
void client
|
void client
|
||||||
.fetchAgentTimeline(agentId, {
|
.fetchAgentTimeline(agentId, {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
cursor: { seq: cursor.endSeq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||||
@@ -862,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
current.epoch === result.cursor.epoch &&
|
|
||||||
current.startSeq === result.cursor.startSeq &&
|
current.startSeq === result.cursor.startSeq &&
|
||||||
current.endSeq === result.cursor.endSeq
|
current.endSeq === result.cursor.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -967,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||||
if (message.type !== "agent_stream") return;
|
if (message.type !== "agent_stream") return;
|
||||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
const { agentId, event, timestamp, seq } = message.payload;
|
||||||
const parsedTimestamp = new Date(timestamp);
|
const parsedTimestamp = new Date(timestamp);
|
||||||
const streamEvent = event as AgentStreamEventPayload;
|
const streamEvent = event as AgentStreamEventPayload;
|
||||||
if (
|
if (
|
||||||
@@ -1009,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
event: streamEvent,
|
event: streamEvent,
|
||||||
seq,
|
seq,
|
||||||
epoch,
|
|
||||||
currentTail,
|
currentTail,
|
||||||
currentHead,
|
currentHead,
|
||||||
currentCursor,
|
currentCursor,
|
||||||
@@ -1033,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
typeof seq === "number" &&
|
typeof seq === "number" &&
|
||||||
typeof epoch === "string" &&
|
|
||||||
current.epoch === epoch &&
|
|
||||||
seq >= current.startSeq &&
|
seq >= current.startSeq &&
|
||||||
seq <= current.endSeq
|
seq <= current.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -1043,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
current.epoch === nextCursor.epoch &&
|
|
||||||
current.startSeq === nextCursor.startSeq &&
|
current.startSeq === nextCursor.startSeq &&
|
||||||
current.endSeq === nextCursor.endSeq
|
current.endSeq === nextCursor.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -1094,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||||
if (message.type !== "workspace_update") return;
|
if (message.type !== "workspace_update") return;
|
||||||
if (message.payload.kind === "remove") {
|
if (message.payload.kind === "remove") {
|
||||||
removeWorkspace(serverId, message.payload.id);
|
removeWorkspace(serverId, String(message.payload.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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",
|
||||||
|
terminal: false,
|
||||||
|
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,
|
||||||
|
supportsTerminalMode: false,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
title: "Agent",
|
||||||
|
cwd: "/tmp",
|
||||||
|
model: null,
|
||||||
|
labels: {},
|
||||||
|
projectPlacement: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reconcilePreviousAgentStatuses", () => {
|
||||||
|
it("preserves previously seen status for existing agents", () => {
|
||||||
|
const previous = new Map([["agent-1", "running" as const]]);
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds newly seen agents from the current snapshot", () => {
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "idle"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes agents that are no longer present", () => {
|
||||||
|
const previous = new Map([
|
||||||
|
["agent-1", "running" as const],
|
||||||
|
["agent-2", "idle" as const],
|
||||||
|
]);
|
||||||
|
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all tracked statuses when the session is unavailable", () => {
|
||||||
|
const previous = new Map([["agent-1", "running" as const]]);
|
||||||
|
|
||||||
|
const result = reconcilePreviousAgentStatuses(previous, undefined);
|
||||||
|
|
||||||
|
expect(result).toEqual(new Map());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
|
export function reconcilePreviousAgentStatuses(
|
||||||
|
previousStatuses: Map<string, AgentLifecycleStatus>,
|
||||||
|
sessionAgents: Map<string, Agent> | undefined,
|
||||||
|
): Map<string, AgentLifecycleStatus> {
|
||||||
|
if (!sessionAgents) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatuses = new Map(previousStatuses);
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const agent of sessionAgents.values()) {
|
||||||
|
seenAgentIds.add(agent.id);
|
||||||
|
if (!nextStatuses.has(agent.id)) {
|
||||||
|
nextStatuses.set(agent.id, agent.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentId of nextStatuses.keys()) {
|
||||||
|
if (!seenAgentIds.has(agentId)) {
|
||||||
|
nextStatuses.delete(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStatuses;
|
||||||
|
}
|
||||||
@@ -9,13 +9,9 @@ import {
|
|||||||
type TimelineCursor,
|
type TimelineCursor,
|
||||||
} from "./session-stream-reducers";
|
} from "./session-stream-reducers";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||||
return {
|
return {
|
||||||
seqStart: seq,
|
seq,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: { type, text },
|
item: { type, text },
|
||||||
timestamp: new Date(1000 + seq).toISOString(),
|
timestamp: new Date(1000 + seq).toISOString(),
|
||||||
@@ -33,22 +29,30 @@ function makeTimelineEvent(
|
|||||||
} as AgentStreamEventPayload;
|
} as AgentStreamEventPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||||
return {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: { type: "user_message", text },
|
item: {
|
||||||
} as AgentStreamEventPayload;
|
type: "tool_call",
|
||||||
|
callId: "call-1",
|
||||||
|
name: "shell",
|
||||||
|
status,
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
direction: "after",
|
direction: "after",
|
||||||
reset: false,
|
startSeq: null,
|
||||||
epoch: "epoch-1",
|
endSeq: null,
|
||||||
startCursor: null,
|
|
||||||
endCursor: null,
|
|
||||||
entries: [],
|
entries: [],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
|||||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||||
event: makeTimelineEvent("hello"),
|
event: makeTimelineEvent("hello"),
|
||||||
seq: undefined,
|
seq: undefined,
|
||||||
epoch: undefined,
|
|
||||||
currentTail: [],
|
currentTail: [],
|
||||||
currentHead: [],
|
currentHead: [],
|
||||||
currentCursor: undefined,
|
currentCursor: undefined,
|
||||||
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
|||||||
timestamp: new Date(2000),
|
timestamp: new Date(2000),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processTimelineResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("processTimelineResponse", () => {
|
describe("processTimelineResponse", () => {
|
||||||
it("returns error path when payload.error is set", () => {
|
it("returns error path when payload.error is set", () => {
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
|
|||||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||||
expect(result.cursorChanged).toBe(false);
|
expect(result.cursorChanged).toBe(false);
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error with no init resolution when no deferred exists", () => {
|
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||||
const result = processTimelineResponse({
|
const provisionalHead: StreamItem[] = [
|
||||||
...baseTimelineInput,
|
|
||||||
isInitializing: true,
|
|
||||||
hasActiveInitDeferred: false,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
error: "timeout",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.error).toBe("timeout");
|
|
||||||
expect(result.initResolution).toBe(null);
|
|
||||||
expect(result.clearInitializing).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces tail and clears head when reset=true", () => {
|
|
||||||
const existingTail: StreamItem[] = [
|
|
||||||
{
|
|
||||||
kind: "user_message",
|
|
||||||
id: "old",
|
|
||||||
text: "old message",
|
|
||||||
timestamp: new Date(500),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const existingHead: StreamItem[] = [
|
|
||||||
{
|
{
|
||||||
kind: "assistant_message",
|
kind: "assistant_message",
|
||||||
id: "head-1",
|
id: "head-1",
|
||||||
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
|
|||||||
|
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
currentTail: existingTail,
|
currentHead: provisionalHead,
|
||||||
currentHead: existingHead,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
reset: true,
|
|
||||||
startCursor: { seq: 1 },
|
|
||||||
endCursor: { seq: 3 },
|
|
||||||
entries: [
|
|
||||||
makeTimelineEntry(1, "first"),
|
|
||||||
makeTimelineEntry(2, "second"),
|
|
||||||
makeTimelineEntry(3, "third"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.tail).not.toBe(existingTail);
|
|
||||||
expect(result.tail.length).toBeGreaterThan(0);
|
|
||||||
expect(result.head).toEqual([]);
|
|
||||||
expect(result.cursorChanged).toBe(true);
|
|
||||||
expect(result.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 3,
|
|
||||||
});
|
|
||||||
expect(result.error).toBe(null);
|
|
||||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
reset: true,
|
|
||||||
entries: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.cursor).toBe(null);
|
|
||||||
expect(result.cursorChanged).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
hasActiveInitDeferred: true,
|
hasActiveInitDeferred: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
epoch: "epoch-1",
|
startSeq: 1,
|
||||||
startCursor: { seq: 1 },
|
endSeq: 5,
|
||||||
endCursor: { seq: 5 },
|
|
||||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bootstrap tail replaces
|
|
||||||
expect(result.tail.length).toBeGreaterThan(0);
|
expect(result.tail.length).toBeGreaterThan(0);
|
||||||
expect(result.head).toEqual([]);
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
startSeq: 1,
|
||||||
endSeq: 5,
|
endSeq: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should have catch-up side effect
|
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
expect(catchUp).toEqual({
|
||||||
expect(catchUp).toBeDefined();
|
type: "catch_up",
|
||||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
cursor: { endSeq: 5 },
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 5,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends incrementally for contiguous seqs", () => {
|
it("prepends older committed history for before pagination", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const currentTail: StreamItem[] = [
|
||||||
epoch: "epoch-1",
|
{
|
||||||
startSeq: 1,
|
kind: "assistant_message",
|
||||||
endSeq: 3,
|
id: "tail-3",
|
||||||
};
|
text: "newer",
|
||||||
|
timestamp: new Date(3000),
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
|
||||||
},
|
},
|
||||||
});
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||||
expect(result.tail.length).toBeGreaterThan(0);
|
|
||||||
expect(result.cursorChanged).toBe(true);
|
|
||||||
expect(result.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 5,
|
|
||||||
});
|
|
||||||
expect(result.error).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects gap and emits catch-up side effect", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
currentCursor: existingCursor,
|
currentTail,
|
||||||
payload: {
|
currentCursor,
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [makeTimelineEntry(10, "far ahead")],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gap should trigger catch-up
|
|
||||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
|
||||||
expect(catchUp).toBeDefined();
|
|
||||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops stale entries silently", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// No new items appended (all dropped as stale)
|
|
||||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops entries with epoch mismatch", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-2",
|
|
||||||
entries: [makeTimelineEntry(6, "different epoch")],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves init when deferred matches direction", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
isInitializing: true,
|
|
||||||
hasActiveInitDeferred: true,
|
|
||||||
initRequestDirection: "after",
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
direction: "after",
|
|
||||||
entries: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.initResolution).toBe("resolve");
|
|
||||||
expect(result.clearInitializing).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not resolve init when directions differ (before vs after)", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
isInitializing: true,
|
|
||||||
hasActiveInitDeferred: true,
|
|
||||||
initRequestDirection: "after",
|
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "before",
|
direction: "before",
|
||||||
entries: [],
|
startSeq: 1,
|
||||||
|
endSeq: 2,
|
||||||
|
entries: [
|
||||||
|
makeTimelineEntry(1, "hello", "user_message"),
|
||||||
|
makeTimelineEntry(2, "older"),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// "before" direction doesn't match "after" initRequestDirection,
|
expect(result.cursorChanged).toBe(true);
|
||||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
expect(result.cursor).toEqual({
|
||||||
expect(result.initResolution).toBe(null);
|
startSeq: 1,
|
||||||
expect(result.clearInitializing).toBe(false);
|
endSeq: 4,
|
||||||
|
});
|
||||||
|
expect(result.tail).toHaveLength(3);
|
||||||
|
expect(result.tail[0]?.kind).toBe("user_message");
|
||||||
|
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||||
|
expect(result.tail[2]).toBe(currentTail[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears initializing even without deferred", () => {
|
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||||
|
const currentHead: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: "head-assistant",
|
||||||
|
text: "partial",
|
||||||
|
timestamp: new Date(120000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||||
|
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
isInitializing: true,
|
currentHead,
|
||||||
hasActiveInitDeferred: false,
|
currentCursor,
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "after",
|
direction: "after",
|
||||||
entries: [],
|
startSeq: 121,
|
||||||
},
|
endSeq: 121,
|
||||||
});
|
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||||
|
|
||||||
expect(result.clearInitializing).toBe(true);
|
|
||||||
expect(result.initResolution).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("always includes flush_pending_updates side effect on success", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
entries: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes cursor when no existing cursor on first entries", () => {
|
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: undefined,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
startSeq: 1,
|
||||||
endSeq: 2,
|
endSeq: 121,
|
||||||
|
});
|
||||||
|
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||||
|
kind: "assistant_message",
|
||||||
|
text: "finalized reply",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||||
|
const currentHead: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: "head-assistant",
|
||||||
|
text: "still streaming",
|
||||||
|
timestamp: new Date(120000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||||
|
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
...baseTimelineInput,
|
||||||
|
currentHead,
|
||||||
|
currentCursor,
|
||||||
|
payload: {
|
||||||
|
...baseTimelineInput.payload,
|
||||||
|
direction: "after",
|
||||||
|
startSeq: null,
|
||||||
|
endSeq: null,
|
||||||
|
entries: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.head).toBe(currentHead);
|
||||||
|
expect(result.cursorChanged).toBe(false);
|
||||||
|
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||||
|
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
...baseTimelineInput,
|
||||||
|
currentCursor,
|
||||||
|
payload: {
|
||||||
|
...baseTimelineInput.payload,
|
||||||
|
direction: "after",
|
||||||
|
startSeq: 125,
|
||||||
|
endSeq: 125,
|
||||||
|
entries: [makeTimelineEntry(125, "far ahead")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cursorChanged).toBe(false);
|
||||||
|
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||||
|
expect(result.sideEffects).toContainEqual({
|
||||||
|
type: "catch_up",
|
||||||
|
cursor: { endSeq: 120 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processAgentStreamEvent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("processAgentStreamEvent", () => {
|
describe("processAgentStreamEvent", () => {
|
||||||
it("passes through non-timeline events without cursor changes", () => {
|
it("treats seq-less timeline events as provisional head updates", () => {
|
||||||
const turnEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: turnEvent,
|
event: makeTimelineEvent("partial"),
|
||||||
seq: undefined,
|
seq: undefined,
|
||||||
epoch: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.changedHead).toBe(true);
|
||||||
|
expect(result.changedTail).toBe(false);
|
||||||
|
expect(result.head).toHaveLength(1);
|
||||||
|
expect(result.head[0]).toMatchObject({
|
||||||
|
kind: "assistant_message",
|
||||||
|
text: "partial",
|
||||||
|
});
|
||||||
expect(result.cursorChanged).toBe(false);
|
expect(result.cursorChanged).toBe(false);
|
||||||
expect(result.cursor).toBe(null);
|
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts timeline event with cursor advance", () => {
|
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const currentHead: StreamItem[] = [
|
||||||
epoch: "epoch-1",
|
{
|
||||||
startSeq: 1,
|
kind: "assistant_message",
|
||||||
endSeq: 4,
|
id: "head-assistant",
|
||||||
};
|
text: "partial",
|
||||||
|
timestamp: new Date(1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: makeTimelineEvent("new chunk"),
|
event: makeTimelineEvent("finalized reply"),
|
||||||
seq: 5,
|
seq: 121,
|
||||||
epoch: "epoch-1",
|
currentHead,
|
||||||
currentCursor: existingCursor,
|
currentCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.changedTail).toBe(true);
|
||||||
|
expect(result.changedHead).toBe(true);
|
||||||
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
startSeq: 1,
|
||||||
endSeq: 5,
|
endSeq: 121,
|
||||||
|
});
|
||||||
|
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||||
|
kind: "assistant_message",
|
||||||
|
text: "finalized reply",
|
||||||
});
|
});
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects gap and emits catch-up side effect", () => {
|
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const provisional = processAgentStreamEvent({
|
||||||
epoch: "epoch-1",
|
...baseStreamInput,
|
||||||
startSeq: 1,
|
event: makeToolCallEvent("running"),
|
||||||
endSeq: 4,
|
seq: undefined,
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const committed = processAgentStreamEvent({
|
||||||
|
...baseStreamInput,
|
||||||
|
event: makeToolCallEvent("completed"),
|
||||||
|
seq: 8,
|
||||||
|
currentHead: provisional.head,
|
||||||
|
currentTail: provisional.tail,
|
||||||
|
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(committed.head).toEqual([]);
|
||||||
|
expect(committed.tail).toHaveLength(1);
|
||||||
|
expect(committed.tail[0]).toMatchObject({
|
||||||
|
kind: "tool_call",
|
||||||
|
payload: {
|
||||||
|
source: "agent",
|
||||||
|
data: {
|
||||||
|
callId: "call-1",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests catch-up when a committed live row skips ahead", () => {
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: makeTimelineEvent("far ahead"),
|
event: makeTimelineEvent("far ahead"),
|
||||||
seq: 10,
|
seq: 125,
|
||||||
epoch: "epoch-1",
|
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||||
currentCursor: existingCursor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
expect(result.changedTail).toBe(false);
|
expect(result.changedTail).toBe(false);
|
||||||
expect(result.changedHead).toBe(false);
|
expect(result.changedHead).toBe(false);
|
||||||
|
|
||||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
|
||||||
expect(catchUp).toBeDefined();
|
|
||||||
expect(catchUp!.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops stale timeline event", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: makeTimelineEvent("old"),
|
|
||||||
seq: 5,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
expect(result.cursorChanged).toBe(false);
|
||||||
|
expect(result.sideEffects).toContainEqual({
|
||||||
|
type: "catch_up",
|
||||||
|
cursor: { endSeq: 120 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears provisional head on terminal turn events without committing it to tail", () => {
|
||||||
|
const result = processAgentStreamEvent({
|
||||||
|
...baseStreamInput,
|
||||||
|
event: {
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
},
|
||||||
|
currentHead: [
|
||||||
|
{
|
||||||
|
kind: "thought",
|
||||||
|
id: "reasoning-1",
|
||||||
|
text: "thinking",
|
||||||
|
timestamp: new Date(1000),
|
||||||
|
status: "loading",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changedHead).toBe(true);
|
||||||
expect(result.changedTail).toBe(false);
|
expect(result.changedTail).toBe(false);
|
||||||
expect(result.changedHead).toBe(false);
|
expect(result.head).toEqual([]);
|
||||||
expect(result.sideEffects).toEqual([]);
|
expect(result.tail).toEqual([]);
|
||||||
});
|
|
||||||
|
|
||||||
it("drops timeline event with epoch mismatch", () => {
|
|
||||||
const existingCursor: TimelineCursor = {
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: makeTimelineEvent("wrong epoch"),
|
|
||||||
seq: 6,
|
|
||||||
epoch: "epoch-2",
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
expect(result.changedTail).toBe(false);
|
|
||||||
expect(result.changedHead).toBe(false);
|
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes cursor when none exists", () => {
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: makeTimelineEvent("first"),
|
|
||||||
seq: 1,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
currentCursor: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.cursorChanged).toBe(true);
|
|
||||||
expect(result.cursor).toEqual({
|
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
|
||||||
endSeq: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
|
||||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnCompletedEvent,
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(true);
|
|
||||||
expect(result.agent).not.toBe(null);
|
|
||||||
expect(result.agent!.status).toBe("idle");
|
|
||||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
|
||||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
|
||||||
const turnFailedEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_failed",
|
|
||||||
provider: "claude",
|
|
||||||
error: "something broke",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnFailedEvent,
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(true);
|
|
||||||
expect(result.agent!.status).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not change agent when status is not running", () => {
|
|
||||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnCompletedEvent,
|
|
||||||
currentAgent: {
|
|
||||||
status: "idle",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(false);
|
|
||||||
expect(result.agent).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not change agent when no agent is provided", () => {
|
|
||||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnCompletedEvent,
|
|
||||||
currentAgent: null,
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(false);
|
|
||||||
expect(result.agent).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
|
||||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: turnCompletedEvent,
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(5000),
|
|
||||||
lastActivityAt: new Date(5000),
|
|
||||||
},
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(true);
|
|
||||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
|
||||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not produce agent patch for non-terminal events", () => {
|
|
||||||
const result = processAgentStreamEvent({
|
|
||||||
...baseStreamInput,
|
|
||||||
event: makeTimelineEvent("just text"),
|
|
||||||
currentAgent: {
|
|
||||||
status: "running",
|
|
||||||
updatedAt: new Date(1000),
|
|
||||||
lastActivityAt: new Date(1000),
|
|
||||||
},
|
|
||||||
seq: 1,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
timestamp: new Date(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.agentChanged).toBe(false);
|
|
||||||
expect(result.agent).toBe(null);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
classifySessionTimelineSeq,
|
classifySessionTimelineSeq,
|
||||||
type SessionTimelineSeqDecision,
|
type SessionTimelineSeqDecision,
|
||||||
@@ -12,38 +12,25 @@ import {
|
|||||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared cursor type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TimelineCursor = {
|
export type TimelineCursor = {
|
||||||
epoch: string;
|
|
||||||
startSeq: number;
|
startSeq: number;
|
||||||
endSeq: number;
|
endSeq: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Side-effect discriminated unions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TimelineReducerSideEffect =
|
export type TimelineReducerSideEffect =
|
||||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||||
| { type: "flush_pending_updates" };
|
| { type: "flush_pending_updates" };
|
||||||
|
|
||||||
export type AgentStreamReducerSideEffect = {
|
export type AgentStreamReducerSideEffect = {
|
||||||
type: "catch_up";
|
type: "catch_up";
|
||||||
cursor: { epoch: string; endSeq: number };
|
cursor: { endSeq: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processTimelineResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type TimelineDirection = "tail" | "before" | "after";
|
type TimelineDirection = "tail" | "before" | "after";
|
||||||
type InitRequestDirection = "tail" | "after";
|
type InitRequestDirection = "tail" | "after";
|
||||||
|
|
||||||
type TimelineResponseEntry = {
|
type TimelineResponseEntry = {
|
||||||
seqStart: number;
|
seq: number;
|
||||||
provider: string;
|
provider: string;
|
||||||
item: Record<string, unknown>;
|
item: Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
reset: boolean;
|
startSeq: number | null;
|
||||||
epoch: string;
|
endSeq: number | null;
|
||||||
startCursor: { seq: number } | null;
|
|
||||||
endCursor: { seq: number } | null;
|
|
||||||
entries: TimelineResponseEntry[];
|
entries: TimelineResponseEntry[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
|
|||||||
sideEffects: TimelineReducerSideEffect[];
|
sideEffects: TimelineReducerSideEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processTimelineResponse(
|
|
||||||
input: ProcessTimelineResponseInput,
|
|
||||||
): ProcessTimelineResponseOutput {
|
|
||||||
const {
|
|
||||||
payload,
|
|
||||||
currentTail,
|
|
||||||
currentHead,
|
|
||||||
currentCursor,
|
|
||||||
isInitializing,
|
|
||||||
hasActiveInitDeferred,
|
|
||||||
initRequestDirection,
|
|
||||||
} = input;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Error path: reject init and leave stream state unchanged
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
if (payload.error) {
|
|
||||||
return {
|
|
||||||
tail: currentTail,
|
|
||||||
head: currentHead,
|
|
||||||
cursor: currentCursor,
|
|
||||||
cursorChanged: false,
|
|
||||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
|
||||||
clearInitializing: isInitializing,
|
|
||||||
error: payload.error,
|
|
||||||
sideEffects: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Convert entries to timeline units
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const timelineUnits = payload.entries.map((entry) => ({
|
|
||||||
seq: entry.seqStart,
|
|
||||||
event: {
|
|
||||||
type: "timeline",
|
|
||||||
provider: entry.provider,
|
|
||||||
item: entry.item,
|
|
||||||
} as AgentStreamEventPayload,
|
|
||||||
timestamp: new Date(entry.timestamp),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const toHydratedEvents = (
|
|
||||||
units: typeof timelineUnits,
|
|
||||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
|
||||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Derive bootstrap policy (replace vs incremental)
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
|
||||||
direction: payload.direction,
|
|
||||||
reset: payload.reset,
|
|
||||||
epoch: payload.epoch,
|
|
||||||
endCursor: payload.endCursor,
|
|
||||||
isInitializing,
|
|
||||||
hasActiveInitDeferred,
|
|
||||||
});
|
|
||||||
const replace = bootstrapPolicy.replace;
|
|
||||||
|
|
||||||
let nextTail = currentTail;
|
|
||||||
let nextHead = currentHead;
|
|
||||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
|
||||||
let cursorChanged = false;
|
|
||||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
|
||||||
|
|
||||||
if (replace) {
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Replace path: full hydration from scratch
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
|
||||||
source: "canonical",
|
|
||||||
});
|
|
||||||
nextHead = [];
|
|
||||||
|
|
||||||
if (payload.startCursor && payload.endCursor) {
|
|
||||||
nextCursor = {
|
|
||||||
epoch: payload.epoch,
|
|
||||||
startSeq: payload.startCursor.seq,
|
|
||||||
endSeq: payload.endCursor.seq,
|
|
||||||
};
|
|
||||||
cursorChanged = true;
|
|
||||||
} else {
|
|
||||||
nextCursor = null;
|
|
||||||
cursorChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bootstrapPolicy.catchUpCursor) {
|
|
||||||
sideEffects.push({
|
|
||||||
type: "catch_up",
|
|
||||||
cursor: bootstrapPolicy.catchUpCursor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (timelineUnits.length > 0) {
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Incremental append path
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
const acceptedUnits: typeof timelineUnits = [];
|
|
||||||
let cursor = currentCursor;
|
|
||||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
|
||||||
|
|
||||||
for (const unit of timelineUnits) {
|
|
||||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
|
||||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
|
||||||
epoch: payload.epoch,
|
|
||||||
seq: unit.seq,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (decision === "gap") {
|
|
||||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptedUnits.push(unit);
|
|
||||||
if (decision === "init") {
|
|
||||||
cursor = {
|
|
||||||
epoch: payload.epoch,
|
|
||||||
startSeq: unit.seq,
|
|
||||||
endSeq: unit.seq,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cursor) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cursor = {
|
|
||||||
...cursor,
|
|
||||||
endSeq: unit.seq,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acceptedUnits.length > 0) {
|
|
||||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
|
||||||
(state, { event, timestamp }) =>
|
|
||||||
reduceStreamUpdate(state, event, timestamp, {
|
|
||||||
source: "canonical",
|
|
||||||
}),
|
|
||||||
currentTail,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
cursor &&
|
|
||||||
(!currentCursor ||
|
|
||||||
currentCursor.epoch !== cursor.epoch ||
|
|
||||||
currentCursor.startSeq !== cursor.startSeq ||
|
|
||||||
currentCursor.endSeq !== cursor.endSeq)
|
|
||||||
) {
|
|
||||||
nextCursor = cursor;
|
|
||||||
cursorChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gapCursor) {
|
|
||||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Flush pending agent updates side effect
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
sideEffects.push({ type: "flush_pending_updates" });
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Init resolution
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
|
||||||
hasActiveInitDeferred,
|
|
||||||
isInitializing,
|
|
||||||
initRequestDirection,
|
|
||||||
responseDirection: payload.direction,
|
|
||||||
reset: payload.reset,
|
|
||||||
});
|
|
||||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
|
||||||
|
|
||||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tail: nextTail,
|
|
||||||
head: nextHead,
|
|
||||||
cursor: nextCursor,
|
|
||||||
cursorChanged,
|
|
||||||
initResolution,
|
|
||||||
clearInitializing,
|
|
||||||
error: null,
|
|
||||||
sideEffects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processAgentStreamEvent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface ProcessAgentStreamEventInput {
|
export interface ProcessAgentStreamEventInput {
|
||||||
event: AgentStreamEventPayload;
|
event: AgentStreamEventPayload;
|
||||||
seq: number | undefined;
|
seq: number | undefined;
|
||||||
epoch: string | undefined;
|
|
||||||
currentTail: StreamItem[];
|
currentTail: StreamItem[];
|
||||||
currentHead: StreamItem[];
|
currentHead: StreamItem[];
|
||||||
currentCursor: TimelineCursor | undefined;
|
currentCursor: TimelineCursor | undefined;
|
||||||
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
|
|||||||
sideEffects: AgentStreamReducerSideEffect[];
|
sideEffects: AgentStreamReducerSideEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cursorsEqual(
|
||||||
|
left: TimelineCursor | null | undefined,
|
||||||
|
right: TimelineCursor | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!left || !right) {
|
||||||
|
return left === right;
|
||||||
|
}
|
||||||
|
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSupersededProvisionalItems(
|
||||||
|
head: StreamItem[],
|
||||||
|
event: AgentStreamEventPayload,
|
||||||
|
): StreamItem[] {
|
||||||
|
if (head.length === 0 || event.type !== "timeline") {
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHead = head;
|
||||||
|
if (event.item.type === "assistant_message") {
|
||||||
|
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||||
|
} else if (event.item.type === "tool_call") {
|
||||||
|
const committedToolCall = event.item;
|
||||||
|
nextHead = head.filter(
|
||||||
|
(item) =>
|
||||||
|
item.kind !== "tool_call" ||
|
||||||
|
item.payload.source !== "agent" ||
|
||||||
|
item.payload.data.callId !== committedToolCall.callId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextHead.length === head.length ? head : nextHead;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processTimelineResponse(
|
||||||
|
input: ProcessTimelineResponseInput,
|
||||||
|
): ProcessTimelineResponseOutput {
|
||||||
|
const {
|
||||||
|
payload,
|
||||||
|
currentTail,
|
||||||
|
currentHead,
|
||||||
|
currentCursor,
|
||||||
|
isInitializing,
|
||||||
|
hasActiveInitDeferred,
|
||||||
|
initRequestDirection,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
if (payload.error) {
|
||||||
|
return {
|
||||||
|
tail: currentTail,
|
||||||
|
head: currentHead,
|
||||||
|
cursor: currentCursor,
|
||||||
|
cursorChanged: false,
|
||||||
|
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||||
|
clearInitializing: isInitializing,
|
||||||
|
error: payload.error,
|
||||||
|
sideEffects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineUnits = payload.entries.map((entry) => ({
|
||||||
|
seq: entry.seq,
|
||||||
|
event: {
|
||||||
|
type: "timeline",
|
||||||
|
provider: entry.provider,
|
||||||
|
item: entry.item,
|
||||||
|
} as AgentStreamEventPayload,
|
||||||
|
timestamp: new Date(entry.timestamp),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||||
|
direction: payload.direction,
|
||||||
|
endSeq: payload.endSeq,
|
||||||
|
isInitializing,
|
||||||
|
hasActiveInitDeferred,
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextTail = currentTail;
|
||||||
|
let nextHead = currentHead;
|
||||||
|
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||||
|
let cursorChanged = false;
|
||||||
|
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||||
|
|
||||||
|
if (bootstrapPolicy.replace) {
|
||||||
|
nextTail = hydrateStreamState(
|
||||||
|
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||||
|
{ source: "canonical" },
|
||||||
|
);
|
||||||
|
nextHead = [];
|
||||||
|
nextCursor =
|
||||||
|
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||||
|
? {
|
||||||
|
startSeq: payload.startSeq,
|
||||||
|
endSeq: payload.endSeq,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||||
|
|
||||||
|
if (bootstrapPolicy.catchUpCursor) {
|
||||||
|
sideEffects.push({
|
||||||
|
type: "catch_up",
|
||||||
|
cursor: bootstrapPolicy.catchUpCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (payload.direction === "before") {
|
||||||
|
const prepended = hydrateStreamState(
|
||||||
|
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||||
|
{ source: "canonical" },
|
||||||
|
);
|
||||||
|
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||||
|
const derivedCursor =
|
||||||
|
typeof payload.startSeq === "number"
|
||||||
|
? {
|
||||||
|
startSeq: payload.startSeq,
|
||||||
|
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||||
|
}
|
||||||
|
: currentCursor;
|
||||||
|
nextCursor = derivedCursor;
|
||||||
|
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||||
|
} else if (timelineUnits.length > 0) {
|
||||||
|
const acceptedUnits: typeof timelineUnits = [];
|
||||||
|
let cursor = currentCursor;
|
||||||
|
let gapCursor: { endSeq: number } | null = null;
|
||||||
|
|
||||||
|
for (const unit of timelineUnits) {
|
||||||
|
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||||
|
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||||
|
seq: unit.seq,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision === "gap") {
|
||||||
|
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (decision === "drop_stale") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptedUnits.push(unit);
|
||||||
|
cursor =
|
||||||
|
decision === "init"
|
||||||
|
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||||
|
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||||
|
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceptedUnits.length > 0) {
|
||||||
|
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||||
|
(state, { event, timestamp }) =>
|
||||||
|
reduceStreamUpdate(state, event, timestamp, {
|
||||||
|
source: "canonical",
|
||||||
|
}),
|
||||||
|
currentTail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||||
|
nextCursor = cursor;
|
||||||
|
cursorChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gapCursor) {
|
||||||
|
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sideEffects.push({ type: "flush_pending_updates" });
|
||||||
|
|
||||||
|
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||||
|
hasActiveInitDeferred,
|
||||||
|
isInitializing,
|
||||||
|
initRequestDirection,
|
||||||
|
responseDirection: payload.direction,
|
||||||
|
});
|
||||||
|
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tail: nextTail,
|
||||||
|
head: nextHead,
|
||||||
|
cursor: nextCursor,
|
||||||
|
cursorChanged,
|
||||||
|
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||||
|
clearInitializing,
|
||||||
|
error: null,
|
||||||
|
sideEffects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function processAgentStreamEvent(
|
export function processAgentStreamEvent(
|
||||||
input: ProcessAgentStreamEventInput,
|
input: ProcessAgentStreamEventInput,
|
||||||
): ProcessAgentStreamEventOutput {
|
): ProcessAgentStreamEventOutput {
|
||||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||||
input;
|
|
||||||
|
|
||||||
let shouldApplyStreamEvent = true;
|
let nextTail = currentTail;
|
||||||
|
let nextHead = currentHead;
|
||||||
|
let changedTail = false;
|
||||||
|
let changedHead = false;
|
||||||
let nextTimelineCursor: TimelineCursor | null = null;
|
let nextTimelineCursor: TimelineCursor | null = null;
|
||||||
let cursorChanged = false;
|
let cursorChanged = false;
|
||||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
if (event.type === "timeline" && typeof seq === "number") {
|
||||||
// Timeline sequencing gate
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
|
||||||
const decision = classifySessionTimelineSeq({
|
const decision = classifySessionTimelineSeq({
|
||||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||||
epoch,
|
|
||||||
seq,
|
seq,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (decision === "init") {
|
if (decision === "gap") {
|
||||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
|
||||||
cursorChanged = true;
|
|
||||||
} else if (decision === "accept") {
|
|
||||||
nextTimelineCursor = {
|
|
||||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
|
||||||
epoch,
|
|
||||||
endSeq: seq,
|
|
||||||
};
|
|
||||||
cursorChanged = true;
|
|
||||||
} else if (decision === "gap") {
|
|
||||||
shouldApplyStreamEvent = false;
|
|
||||||
if (currentCursor) {
|
if (currentCursor) {
|
||||||
sideEffects.push({
|
sideEffects.push({
|
||||||
type: "catch_up",
|
type: "catch_up",
|
||||||
cursor: {
|
cursor: { endSeq: currentCursor.endSeq },
|
||||||
epoch: currentCursor.epoch,
|
|
||||||
endSeq: currentCursor.endSeq,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else if (decision !== "drop_stale") {
|
||||||
// drop_stale or drop_epoch
|
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||||
shouldApplyStreamEvent = false;
|
source: "canonical",
|
||||||
|
});
|
||||||
|
changedTail = nextTail !== currentTail;
|
||||||
|
|
||||||
|
nextHead = removeSupersededProvisionalItems(currentHead, event);
|
||||||
|
changedHead = nextHead !== currentHead;
|
||||||
|
|
||||||
|
nextTimelineCursor =
|
||||||
|
decision === "init"
|
||||||
|
? { startSeq: seq, endSeq: seq }
|
||||||
|
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||||
|
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||||
}
|
}
|
||||||
|
} else if (event.type === "timeline") {
|
||||||
|
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||||
|
source: "live",
|
||||||
|
});
|
||||||
|
changedHead = nextHead !== currentHead;
|
||||||
|
} else if (
|
||||||
|
(event.type === "turn_completed" ||
|
||||||
|
event.type === "turn_canceled" ||
|
||||||
|
event.type === "turn_failed") &&
|
||||||
|
currentHead.length > 0
|
||||||
|
) {
|
||||||
|
nextHead = [];
|
||||||
|
changedHead = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Apply stream event to tail/head
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
|
||||||
? applyStreamEvent({
|
|
||||||
tail: currentTail,
|
|
||||||
head: currentHead,
|
|
||||||
event,
|
|
||||||
timestamp,
|
|
||||||
source: "live",
|
|
||||||
})
|
|
||||||
: {
|
|
||||||
tail: currentTail,
|
|
||||||
head: currentHead,
|
|
||||||
changedTail: false,
|
|
||||||
changedHead: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Optimistic lifecycle status
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
let agentPatch: AgentPatch | null = null;
|
let agentPatch: AgentPatch | null = null;
|
||||||
let agentChanged = false;
|
let agentChanged = false;
|
||||||
|
|
||||||
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tail,
|
tail: nextTail,
|
||||||
head,
|
head: nextHead,
|
||||||
changedTail,
|
changedTail,
|
||||||
changedHead,
|
changedHead,
|
||||||
cursor: nextTimelineCursor,
|
cursor: nextTimelineCursor,
|
||||||
|
|||||||
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||||
import {
|
import {
|
||||||
deriveBootstrapTailTimelinePolicy,
|
deriveBootstrapTailTimelinePolicy,
|
||||||
|
deriveInitialTimelineRequest,
|
||||||
shouldResolveTimelineInit,
|
shouldResolveTimelineInit,
|
||||||
} from "./session-timeline-bootstrap-policy";
|
} from "./session-timeline-bootstrap-policy";
|
||||||
|
|
||||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
describe("deriveInitialTimelineRequest", () => {
|
||||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
it("uses tail bootstrap when history has not synced yet", () => {
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
expect(
|
||||||
direction: "after",
|
deriveInitialTimelineRequest({
|
||||||
reset: true,
|
cursor: { seq: 42 },
|
||||||
epoch: "epoch-1",
|
hasAuthoritativeHistory: false,
|
||||||
endCursor: { seq: 200 },
|
initialTimelineLimit: 200,
|
||||||
isInitializing: false,
|
}),
|
||||||
hasActiveInitDeferred: false,
|
).toEqual({
|
||||||
|
direction: "tail",
|
||||||
|
limit: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(policy.replace).toBe(true);
|
|
||||||
expect(policy.catchUpCursor).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||||
|
expect(
|
||||||
|
deriveInitialTimelineRequest({
|
||||||
|
cursor: { seq: 42 },
|
||||||
|
hasAuthoritativeHistory: true,
|
||||||
|
initialTimelineLimit: 200,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
direction: "after",
|
||||||
|
cursor: { seq: 42 },
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
const advancedCursor = { endSeq: 205 };
|
||||||
const tailSeqStart = 101;
|
const tailSeqStart = 101;
|
||||||
const tailSeqEnd = 200;
|
const tailSeqEnd = 200;
|
||||||
|
|
||||||
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||||
const decision = classifySessionTimelineSeq({
|
const decision = classifySessionTimelineSeq({
|
||||||
cursor: advancedCursor,
|
cursor: advancedCursor,
|
||||||
epoch: "epoch-1",
|
|
||||||
seq,
|
seq,
|
||||||
});
|
});
|
||||||
if (decision === "accept" || decision === "init") {
|
if (decision === "accept" || decision === "init") {
|
||||||
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
|
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
const policy = deriveBootstrapTailTimelinePolicy({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
reset: false,
|
endSeq: 200,
|
||||||
epoch: "epoch-1",
|
|
||||||
endCursor: { seq: 200 },
|
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
hasActiveInitDeferred: true,
|
hasActiveInitDeferred: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(policy.replace).toBe(true);
|
expect(policy.replace).toBe(true);
|
||||||
expect(policy.catchUpCursor).toEqual({
|
expect(policy.catchUpCursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 200,
|
endSeq: 200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
const policy = deriveBootstrapTailTimelinePolicy({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
reset: false,
|
endSeq: 200,
|
||||||
epoch: "epoch-1",
|
|
||||||
endCursor: { seq: 200 },
|
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
hasActiveInitDeferred: false,
|
hasActiveInitDeferred: false,
|
||||||
});
|
});
|
||||||
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
responseDirection: "tail",
|
responseDirection: "tail",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
responseDirection: "after",
|
responseDirection: "after",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "after",
|
initRequestDirection: "after",
|
||||||
responseDirection: "after",
|
responseDirection: "after",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type InitialTimelineCursor = {
|
type InitialTimelineCursor = {
|
||||||
epoch: string;
|
|
||||||
seq: number;
|
seq: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
|
|||||||
initialTimelineLimit: number;
|
initialTimelineLimit: number;
|
||||||
}): {
|
}): {
|
||||||
direction: "tail" | "after";
|
direction: "tail" | "after";
|
||||||
cursor?: { epoch: string; seq: number };
|
cursor?: { seq: number };
|
||||||
limit: number;
|
limit: number;
|
||||||
projection: "canonical";
|
|
||||||
} {
|
} {
|
||||||
if (!hasAuthoritativeHistory || !cursor) {
|
if (!hasAuthoritativeHistory || !cursor) {
|
||||||
return {
|
return {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: initialTimelineLimit,
|
limit: initialTimelineLimit,
|
||||||
projection: "canonical",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
cursor: { seq: cursor.seq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveBootstrapTailTimelinePolicy({
|
export function deriveBootstrapTailTimelinePolicy({
|
||||||
direction,
|
direction,
|
||||||
reset,
|
endSeq,
|
||||||
epoch,
|
|
||||||
endCursor,
|
|
||||||
isInitializing,
|
isInitializing,
|
||||||
hasActiveInitDeferred,
|
hasActiveInitDeferred,
|
||||||
}: {
|
}: {
|
||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
reset: boolean;
|
endSeq: number | null;
|
||||||
epoch: string;
|
|
||||||
endCursor: BootstrapTailCursor;
|
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
hasActiveInitDeferred: boolean;
|
hasActiveInitDeferred: boolean;
|
||||||
}): {
|
}): {
|
||||||
replace: boolean;
|
replace: boolean;
|
||||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
catchUpCursor: { endSeq: number } | null;
|
||||||
} {
|
} {
|
||||||
if (reset) {
|
|
||||||
return { replace: true, catchUpCursor: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||||
if (!isBootstrapTailInit) {
|
if (!isBootstrapTailInit) {
|
||||||
return { replace: false, catchUpCursor: null };
|
return { replace: false, catchUpCursor: null };
|
||||||
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
replace: true,
|
replace: true,
|
||||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
|
|||||||
isInitializing,
|
isInitializing,
|
||||||
initRequestDirection,
|
initRequestDirection,
|
||||||
responseDirection,
|
responseDirection,
|
||||||
reset,
|
|
||||||
}: {
|
}: {
|
||||||
hasActiveInitDeferred: boolean;
|
hasActiveInitDeferred: boolean;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
initRequestDirection: InitRequestDirection;
|
initRequestDirection: InitRequestDirection;
|
||||||
responseDirection: TimelineDirection;
|
responseDirection: TimelineDirection;
|
||||||
reset: boolean;
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!hasActiveInitDeferred || !isInitializing) {
|
if (!hasActiveInitDeferred || !isInitializing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (reset) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return responseDirection === initRequestDirection;
|
return responseDirection === initRequestDirection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("accepts contiguous forward seq", () => {
|
it("accepts contiguous forward seq", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
cursor: { endSeq: 4 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 5,
|
seq: 5,
|
||||||
}),
|
}),
|
||||||
).toBe("accept");
|
).toBe("accept");
|
||||||
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("drops stale seq older than the current end", () => {
|
it("drops stale seq older than the current end", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
cursor: { endSeq: 8 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 7,
|
seq: 7,
|
||||||
}),
|
}),
|
||||||
).toBe("drop_stale");
|
).toBe("drop_stale");
|
||||||
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("drops duplicate replay seq equal to the current end", () => {
|
it("drops duplicate replay seq equal to the current end", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
cursor: { endSeq: 8 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 8,
|
seq: 8,
|
||||||
}),
|
}),
|
||||||
).toBe("drop_stale");
|
).toBe("drop_stale");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops epoch mismatch", () => {
|
|
||||||
expect(
|
|
||||||
classifySessionTimelineSeq({
|
|
||||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
|
||||||
epoch: "epoch-2",
|
|
||||||
seq: 5,
|
|
||||||
}),
|
|
||||||
).toBe("drop_epoch");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes when cursor is null", () => {
|
it("initializes when cursor is null", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: null,
|
cursor: null,
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 1,
|
seq: 1,
|
||||||
}),
|
}),
|
||||||
).toBe("init");
|
).toBe("init");
|
||||||
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("classifies forward gaps", () => {
|
it("classifies forward gaps", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
cursor: { endSeq: 4 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 9,
|
seq: 9,
|
||||||
}),
|
}),
|
||||||
).toBe("gap");
|
).toBe("gap");
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
export type SessionTimelineSeqCursor =
|
export type SessionTimelineSeqCursor =
|
||||||
| {
|
| {
|
||||||
epoch: string;
|
|
||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||||
|
|
||||||
export function classifySessionTimelineSeq({
|
export function classifySessionTimelineSeq({
|
||||||
cursor,
|
cursor,
|
||||||
epoch,
|
|
||||||
seq,
|
seq,
|
||||||
}: {
|
}: {
|
||||||
cursor: SessionTimelineSeqCursor;
|
cursor: SessionTimelineSeqCursor;
|
||||||
epoch: string;
|
|
||||||
seq: number;
|
seq: number;
|
||||||
}): SessionTimelineSeqDecision {
|
}): SessionTimelineSeqDecision {
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
return "init";
|
return "init";
|
||||||
}
|
}
|
||||||
if (cursor.epoch !== epoch) {
|
|
||||||
return "drop_epoch";
|
|
||||||
}
|
|
||||||
if (seq <= cursor.endSeq) {
|
if (seq <= cursor.endSeq) {
|
||||||
return "drop_stale";
|
return "drop_stale";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getLeftSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
|
|
||||||
// Initialize based on current state
|
// Initialize based on current state
|
||||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
|
nextIsOpen: isOpen,
|
||||||
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
|
const previousIsOpen = prevIsOpen.current;
|
||||||
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevIsOpen.current = isOpen;
|
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(-windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function DesktopPermissionsSection() {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
isSendingTestNotification,
|
isSendingTestNotification,
|
||||||
|
testNotificationError,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
@@ -58,6 +59,11 @@ export function DesktopPermissionsSection() {
|
|||||||
void sendTestNotification();
|
void sendTestNotification();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{testNotificationError ? (
|
||||||
|
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
|
||||||
|
{testNotificationError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<DesktopPermissionRow
|
<DesktopPermissionRow
|
||||||
title="Microphone"
|
title="Microphone"
|
||||||
showBorder
|
showBorder
|
||||||
@@ -80,4 +86,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
|||||||
export type DesktopDaemonStatus = {
|
export type DesktopDaemonStatus = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
status: DesktopDaemonState;
|
status: DesktopDaemonState;
|
||||||
listen: string;
|
listen: string | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
home: string;
|
home: string;
|
||||||
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
|||||||
return {
|
return {
|
||||||
serverId: toStringOrNull(raw.serverId) ?? "",
|
serverId: toStringOrNull(raw.serverId) ?? "",
|
||||||
status: parseDesktopDaemonState(raw.status),
|
status: parseDesktopDaemonState(raw.status),
|
||||||
listen: toStringOrNull(raw.listen) ?? "",
|
listen: toStringOrNull(raw.listen),
|
||||||
hostname: toStringOrNull(raw.hostname),
|
hostname: toStringOrNull(raw.hostname),
|
||||||
pid: toNumberOrNull(raw.pid),
|
pid: toNumberOrNull(raw.pid),
|
||||||
home: toStringOrNull(raw.home) ?? "",
|
home: toStringOrNull(raw.home) ?? "",
|
||||||
|
|||||||
@@ -27,3 +27,12 @@ export async function isDesktopFullscreen(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
return await win.isFullscreen();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export interface DesktopOpenerBridge {
|
|||||||
openUrl?: (url: string) => Promise<void>;
|
openUrl?: (url: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopMenuBridge {
|
||||||
|
showContextMenu?: (input?: {
|
||||||
|
kind?: "terminal";
|
||||||
|
hasSelection?: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopWindowBridge {
|
export interface DesktopWindowBridge {
|
||||||
label?: string;
|
label?: string;
|
||||||
startMove?: (screenX: number, screenY: number) => void;
|
startMove?: (screenX: number, screenY: number) => void;
|
||||||
@@ -44,6 +51,7 @@ export interface DesktopWindowBridge {
|
|||||||
endMove?: () => void;
|
endMove?: () => void;
|
||||||
toggleMaximize?: () => Promise<void>;
|
toggleMaximize?: () => Promise<void>;
|
||||||
isFullscreen?: () => Promise<boolean>;
|
isFullscreen?: () => Promise<boolean>;
|
||||||
|
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
|
||||||
onResized?: <TEvent = unknown>(
|
onResized?: <TEvent = unknown>(
|
||||||
handler: (event: TEvent) => void,
|
handler: (event: TEvent) => void,
|
||||||
) => Promise<() => void> | (() => void);
|
) => Promise<() => void> | (() => void);
|
||||||
@@ -73,6 +81,7 @@ export interface DesktopHostBridge {
|
|||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
notification?: DesktopNotificationBridge;
|
notification?: DesktopNotificationBridge;
|
||||||
opener?: DesktopOpenerBridge;
|
opener?: DesktopOpenerBridge;
|
||||||
|
menu?: DesktopMenuBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface UseDesktopPermissionsReturn {
|
|||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
requestingPermission: DesktopPermissionKind | null;
|
requestingPermission: DesktopPermissionKind | null;
|
||||||
isSendingTestNotification: boolean;
|
isSendingTestNotification: boolean;
|
||||||
|
testNotificationError: string | null;
|
||||||
refreshPermissions: () => Promise<void>;
|
refreshPermissions: () => Promise<void>;
|
||||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||||
sendTestNotification: () => Promise<void>;
|
sendTestNotification: () => Promise<void>;
|
||||||
@@ -112,22 +113,25 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
[isDesktop, refreshPermissions],
|
[isDesktop, refreshPermissions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const sendTestNotification = useCallback(async () => {
|
const sendTestNotification = useCallback(async () => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSendingTestNotification(true);
|
setIsSendingTestNotification(true);
|
||||||
|
setTestNotificationError(null);
|
||||||
try {
|
try {
|
||||||
const sent = await sendOsNotification({
|
const sent = await sendOsNotification({
|
||||||
title: "Paseo notification test",
|
title: "Paseo notification test",
|
||||||
body: "If you can see this, desktop notifications work.",
|
body: "If you can see this, desktop notifications work.",
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
console.warn("[Settings] Desktop test notification was not delivered");
|
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Settings] Failed to send desktop test notification", error);
|
setTestNotificationError("Failed to send notification.");
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsSendingTestNotification(false);
|
setIsSendingTestNotification(false);
|
||||||
@@ -149,6 +153,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
isSendingTestNotification,
|
isSendingTestNotification,
|
||||||
|
testNotificationError,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
|
|||||||
|
|
||||||
export function UpdateBanner() {
|
export function UpdateBanner() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
|
const {
|
||||||
useDesktopAppUpdater();
|
isDesktop,
|
||||||
|
status,
|
||||||
|
availableUpdate,
|
||||||
|
errorMessage,
|
||||||
|
checkForUpdates,
|
||||||
|
installUpdate,
|
||||||
|
isInstalling,
|
||||||
|
} = useDesktopAppUpdater();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -33,24 +40,36 @@ export function UpdateBanner() {
|
|||||||
|
|
||||||
if (!isDesktop) return null;
|
if (!isDesktop) return null;
|
||||||
if (dismissed) return null;
|
if (dismissed) return null;
|
||||||
if (status !== "available" && status !== "installed") return null;
|
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
|
||||||
|
return null;
|
||||||
|
|
||||||
const isInstalled = status === "installed";
|
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 (
|
return (
|
||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<View style={styles.banner}>
|
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
|
||||||
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
<X size={14} color={theme.colors.foregroundMuted} />
|
</Pressable>
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
|
<View style={styles.banner}>
|
||||||
<View style={styles.textSection}>
|
<View style={styles.textSection}>
|
||||||
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
|
<Text style={styles.title}>{getTitle()}</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>{getSubtitle()}</Text>
|
||||||
{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>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
@@ -61,7 +80,7 @@ export function UpdateBanner() {
|
|||||||
<Text style={styles.outlineButtonText}>What's new</Text>
|
<Text style={styles.outlineButtonText}>What's new</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{!isInstalled && (
|
{!isInstalled && !isError && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void installUpdate()}
|
onPress={() => void installUpdate()}
|
||||||
disabled={isInstalling}
|
disabled={isInstalling}
|
||||||
@@ -76,6 +95,15 @@ export function UpdateBanner() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void checkForUpdates()}
|
||||||
|
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>Retry</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -109,15 +137,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: theme.spacing[2],
|
top: -8,
|
||||||
left: theme.spacing[2],
|
left: -8,
|
||||||
padding: theme.spacing[1],
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
textSection: {
|
textSection: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
paddingTop: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps provider thinking preference when it is valid for the effective model", () => {
|
it("prefers provider defaults on fresh drafts", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "medium",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
|
|||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
{ model: "default" },
|
{ model: "default" },
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
it("normalizes legacy model id 'default' to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
{ model: "default" },
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
{ thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
codexModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves thinking unset when the model exposes options without a provider default", () => {
|
||||||
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
claudeModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "claude",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("resolves provider only from allowed provider map", () => {
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
import {
|
||||||
|
useFormPreferences,
|
||||||
|
mergeProviderPreferences,
|
||||||
|
type FormPreferences,
|
||||||
|
type ProviderPreferences,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
// Explicit overrides from URL params or "New Agent" button
|
||||||
export interface FormInitialValues {
|
export interface FormInitialValues {
|
||||||
@@ -61,7 +66,7 @@ type UseAgentFormStateOptions = {
|
|||||||
onlineServerIds?: string[];
|
onlineServerIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseAgentFormStateResult = {
|
export type UseAgentFormStateResult = {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
setSelectedServerId: (value: string | null) => void;
|
setSelectedServerId: (value: string | null) => void;
|
||||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||||
@@ -102,7 +107,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
|
|||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -117,6 +122,10 @@ function resolveDefaultModel(
|
|||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
function resolveEffectiveModel(
|
||||||
availableModels: AgentModelDefinition[] | null,
|
availableModels: AgentModelDefinition[] | null,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
@@ -134,9 +143,31 @@ function resolveEffectiveModel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that resolves form state from multiple data sources.
|
* Pure function that resolves form state from multiple data sources.
|
||||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||||
*
|
*
|
||||||
* Only resolves fields that haven't been user-modified.
|
* Only resolves fields that haven't been user-modified.
|
||||||
*/
|
*/
|
||||||
@@ -195,25 +226,22 @@ function resolveFormState(
|
|||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
|
||||||
if (initialModel) {
|
if (initialModel) {
|
||||||
// If models aren't loaded yet, trust the initial value
|
|
||||||
// It will be validated once models load
|
|
||||||
if (!availableModels || isValidModel(initialModel)) {
|
if (!availableModels || isValidModel(initialModel)) {
|
||||||
result.model = initialModel;
|
result.model = initialModel;
|
||||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
|
||||||
result.model = preferredModel;
|
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = defaultModelId;
|
||||||
}
|
}
|
||||||
} else if (preferredModel) {
|
} else if (preferredModel) {
|
||||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
|
||||||
// We'll validate once models load and clear it if it isn't available.
|
|
||||||
if (!availableModels || isValidModel(preferredModel)) {
|
if (!availableModels || isValidModel(preferredModel)) {
|
||||||
result.model = preferredModel;
|
result.model = preferredModel;
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = defaultModelId;
|
||||||
}
|
}
|
||||||
|
} else if (defaultModelId) {
|
||||||
|
result.model = defaultModelId;
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = "";
|
||||||
}
|
}
|
||||||
@@ -224,13 +252,17 @@ function resolveFormState(
|
|||||||
typeof initialValues?.thinkingOptionId === "string"
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!userModified.thinkingOptionId) {
|
if (!userModified.thinkingOptionId) {
|
||||||
|
const effectiveModelId = result.model.trim();
|
||||||
|
const preferredThinking = effectiveModelId
|
||||||
|
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
if (initialThinkingOptionId.length > 0) {
|
if (initialThinkingOptionId.length > 0) {
|
||||||
result.thinkingOptionId = initialThinkingOptionId;
|
result.thinkingOptionId = initialThinkingOptionId;
|
||||||
} else if (preferredThinkingOptionId.length > 0) {
|
} else if (preferredThinking.length > 0) {
|
||||||
result.thinkingOptionId = preferredThinkingOptionId;
|
result.thinkingOptionId = preferredThinking;
|
||||||
} else {
|
} else {
|
||||||
result.thinkingOptionId = "";
|
result.thinkingOptionId = "";
|
||||||
}
|
}
|
||||||
@@ -238,27 +270,17 @@ function resolveFormState(
|
|||||||
|
|
||||||
// Validate thinking option once model metadata is available.
|
// Validate thinking option once model metadata is available.
|
||||||
if (availableModels) {
|
if (availableModels) {
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
availableModels,
|
||||||
if (thinkingOptions.length === 0) {
|
modelId: result.model,
|
||||||
result.thinkingOptionId = "";
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
} else {
|
});
|
||||||
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
|
|
||||||
const defaultThinkingOptionId =
|
|
||||||
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
|
|
||||||
result.thinkingOptionId = defaultThinkingOptionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Resolve serverId (independent)
|
// 5. Resolve serverId (independent)
|
||||||
// Only use stored serverId if the host still exists in the registry
|
|
||||||
if (!userModified.serverId) {
|
if (!userModified.serverId) {
|
||||||
if (initialValues?.serverId !== undefined) {
|
if (initialValues?.serverId !== undefined) {
|
||||||
result.serverId = initialValues.serverId;
|
result.serverId = initialValues.serverId;
|
||||||
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
|
|
||||||
result.serverId = preferences.serverId;
|
|
||||||
}
|
}
|
||||||
// else keep current
|
// else keep current
|
||||||
}
|
}
|
||||||
@@ -267,8 +289,6 @@ function resolveFormState(
|
|||||||
if (!userModified.workingDir) {
|
if (!userModified.workingDir) {
|
||||||
if (initialValues?.workingDir !== undefined) {
|
if (initialValues?.workingDir !== undefined) {
|
||||||
result.workingDir = initialValues.workingDir;
|
result.workingDir = initialValues.workingDir;
|
||||||
} else if (preferences?.workingDir) {
|
|
||||||
result.workingDir = preferences.workingDir;
|
|
||||||
}
|
}
|
||||||
// else keep current (empty string)
|
// else keep current (empty string)
|
||||||
}
|
}
|
||||||
@@ -313,7 +333,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
preferences,
|
preferences,
|
||||||
isLoading: isPreferencesLoading,
|
isLoading: isPreferencesLoading,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -537,107 +556,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible || !isCreateFlow) return;
|
|
||||||
if (isPreferencesLoading) return;
|
|
||||||
if (userModified.serverId) return;
|
|
||||||
const serverId = formState.serverId;
|
|
||||||
if (!serverId) return;
|
|
||||||
if (preferences?.serverId === serverId) return;
|
|
||||||
void updatePreferences({ serverId });
|
|
||||||
}, [
|
|
||||||
isVisible,
|
|
||||||
isCreateFlow,
|
|
||||||
isPreferencesLoading,
|
|
||||||
userModified.serverId,
|
|
||||||
formState.serverId,
|
|
||||||
preferences?.serverId,
|
|
||||||
updatePreferences,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// User setters - mark fields as modified and persist to preferences
|
// User setters - mark fields as modified and persist to preferences
|
||||||
const setSelectedServerIdFromUser = useCallback(
|
const setSelectedServerIdFromUser = useCallback(
|
||||||
(value: string | null) => {
|
(value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||||
void updatePreferences({ serverId: value ?? undefined });
|
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
(provider: AgentProvider) => {
|
(provider: AgentProvider) => {
|
||||||
setFormState((prev) => ({ ...prev, provider }));
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
|
||||||
void updatePreferences({ provider });
|
|
||||||
|
|
||||||
// When provider changes, reset mode and model to provider defaults
|
|
||||||
// (unless user has explicitly set them)
|
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
|
const isValidModel = (m: string) =>
|
||||||
|
providerModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
const nextModelId =
|
||||||
|
preferredModel && (!providerModels || isValidModel(preferredModel))
|
||||||
|
? preferredModel
|
||||||
|
: defaultModelId;
|
||||||
|
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
const nextModeId =
|
||||||
|
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
|
||||||
|
? providerPrefs.mode
|
||||||
|
: providerDef?.defaultModeId ?? "";
|
||||||
|
|
||||||
|
const preferredThinking = nextModelId
|
||||||
|
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
|
||||||
|
: "";
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: preferredThinking,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||||
|
void updatePreferences({ provider });
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: nextModeId,
|
||||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
model: nextModelId,
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(provider: AgentProvider, modelId: string) => {
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
model: modelId,
|
model: nextModelId,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: providerDef?.defaultModeId ?? "",
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
void updateProviderPreferences(provider, { model: modelId });
|
|
||||||
},
|
},
|
||||||
[
|
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||||
preferences?.providerPreferences,
|
|
||||||
providerDefinitionMap,
|
|
||||||
updatePreferences,
|
|
||||||
updateProviderPreferences,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
setFormState((prev) => ({ ...prev, modeId }));
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: userModified.thinkingOptionId
|
||||||
|
? formStateRef.current.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
setUserModified((prev) => ({ ...prev, model: true }));
|
||||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[availableModels, userModified.thinkingOptionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
@@ -648,9 +679,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
(value: string) => {
|
(value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||||
void updatePreferences({ workingDir: value });
|
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSelectedServerId = useCallback((value: string | null) => {
|
const setSelectedServerId = useCallback((value: string | null) => {
|
||||||
@@ -662,39 +692,39 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [providerModelsQuery]);
|
}, [providerModelsQuery]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const providerPreferenceUpdates: {
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
mode: string;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
model: string;
|
const nextPreferences = mergeProviderPreferences({
|
||||||
thinkingOptionId?: string;
|
preferences: preferences ?? {},
|
||||||
} = {
|
|
||||||
mode: formState.modeId,
|
|
||||||
model: formState.model,
|
|
||||||
};
|
|
||||||
if (userModified.thinkingOptionId) {
|
|
||||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updatePreferences({
|
|
||||||
workingDir: formState.workingDir,
|
|
||||||
provider: formState.provider,
|
provider: formState.provider,
|
||||||
serverId: formState.serverId ?? undefined,
|
updates: {
|
||||||
|
model: modelId || undefined,
|
||||||
|
mode: formState.modeId || undefined,
|
||||||
|
...(modelId && formState.thinkingOptionId
|
||||||
|
? {
|
||||||
|
thinkingByModel: {
|
||||||
|
[modelId]: formState.thinkingOptionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies Partial<ProviderPreferences>,
|
||||||
});
|
});
|
||||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
|
||||||
|
await updatePreferences(nextPreferences);
|
||||||
}, [
|
}, [
|
||||||
formState.modeId,
|
availableModels,
|
||||||
formState.model,
|
formState.model,
|
||||||
|
formState.modeId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.serverId,
|
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
formState.workingDir,
|
preferences?.providerPreferences,
|
||||||
userModified.thinkingOptionId,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
const modeOptions = agentDefinition?.modes ?? [];
|
const modeOptions = agentDefinition?.modes ?? [];
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||||
const modelError =
|
const modelError =
|
||||||
@@ -711,7 +741,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
setProviderFromUser,
|
setProviderFromUser,
|
||||||
selectedMode: formState.modeId,
|
selectedMode: formState.modeId,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
selectedModel: formState.model,
|
selectedModel: resolvedModelId,
|
||||||
setModelFromUser,
|
setModelFromUser,
|
||||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||||
setThinkingOptionFromUser,
|
setThinkingOptionFromUser,
|
||||||
@@ -737,7 +767,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
formState.serverId,
|
formState.serverId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.modeId,
|
formState.modeId,
|
||||||
formState.model,
|
resolvedModelId,
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
formState.workingDir,
|
formState.workingDir,
|
||||||
setSelectedServerId,
|
setSelectedServerId,
|
||||||
@@ -771,5 +801,7 @@ export type CreateAgentInitialValues = FormInitialValues;
|
|||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { __private__ } from "./use-agent-initialization";
|
import { __private__ } from "./use-agent-initialization";
|
||||||
|
|
||||||
describe("useAgentInitialization timeline request policy", () => {
|
describe("useAgentInitialization timeline request policy", () => {
|
||||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: {
|
cursor: {
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 42,
|
seq: 42,
|
||||||
},
|
},
|
||||||
hasAuthoritativeHistory: false,
|
hasAuthoritativeHistory: false,
|
||||||
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 200,
|
limit: 200,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: null,
|
cursor: null,
|
||||||
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 200,
|
limit: 200,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: {
|
cursor: {
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 42,
|
seq: 42,
|
||||||
},
|
},
|
||||||
hasAuthoritativeHistory: true,
|
hasAuthoritativeHistory: true,
|
||||||
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: "epoch-1", seq: 42 },
|
cursor: { seq: 42 },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
|||||||
const hasAuthoritativeHistory =
|
const hasAuthoritativeHistory =
|
||||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||||
const timelineRequest = deriveInitialTimelineRequest({
|
const timelineRequest = deriveInitialTimelineRequest({
|
||||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||||
hasAuthoritativeHistory,
|
hasAuthoritativeHistory,
|
||||||
initialTimelineLimit,
|
initialTimelineLimit,
|
||||||
});
|
});
|
||||||
@@ -107,7 +107,6 @@ export function useAgentInitialization({
|
|||||||
await client.fetchAgentTimeline(agentId, {
|
await client.fetchAgentTimeline(agentId, {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: initialTimelineLimit,
|
limit: initialTimelineLimit,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAgentInitializing(agentId, false);
|
setAgentInitializing(agentId, false);
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
|
||||||
|
const { asyncStorage } = vi.hoisted(() => ({
|
||||||
|
asyncStorage: new Map<string, string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: {
|
||||||
|
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
asyncStorage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
asyncStorage.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/service", () => ({
|
||||||
|
garbageCollectAttachments: async () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./use-agent-form-state", () => ({
|
||||||
|
useAgentFormState: () => ({
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
setSelectedServerId: () => undefined,
|
||||||
|
setSelectedServerIdFromUser: () => undefined,
|
||||||
|
selectedProvider: "codex",
|
||||||
|
setProviderFromUser: () => undefined,
|
||||||
|
selectedMode: "auto",
|
||||||
|
setModeFromUser: () => undefined,
|
||||||
|
selectedModel: "",
|
||||||
|
setModelFromUser: () => undefined,
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
setThinkingOptionFromUser: () => undefined,
|
||||||
|
workingDir: "/repo",
|
||||||
|
setWorkingDir: () => undefined,
|
||||||
|
setWorkingDirFromUser: () => undefined,
|
||||||
|
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
||||||
|
providerDefinitionMap: new Map(),
|
||||||
|
agentDefinition: undefined,
|
||||||
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
|
availableModels: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allProviderModels: new Map([
|
||||||
|
[
|
||||||
|
"codex",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
isAllModelsLoading: false,
|
||||||
|
availableThinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
isModelLoading: false,
|
||||||
|
modelError: null,
|
||||||
|
refreshProviderModels: () => undefined,
|
||||||
|
setProviderAndModelFromUser: () => undefined,
|
||||||
|
workingDirIsEmpty: false,
|
||||||
|
persistFormPreferences: async () => undefined,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
value: {
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentInputDraft live contract", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
asyncStorage.clear();
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||||
|
url: "http://localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
value: dom.window.document,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
value: dom.window.navigator,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => {
|
||||||
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
|
const image: AttachmentMetadata = {
|
||||||
|
id: "attachment-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachments/1",
|
||||||
|
createdAt: 1,
|
||||||
|
fileName: "image.png",
|
||||||
|
byteSize: 128,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error("Expected hook result");
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe({ draftKey }: { draftKey: string }) {
|
||||||
|
latest = useAgentInputDraft({
|
||||||
|
draftKey,
|
||||||
|
composer: {
|
||||||
|
initialServerId: "host-1",
|
||||||
|
initialValues: { workingDir: "/repo" },
|
||||||
|
isVisible: true,
|
||||||
|
onlineServerIds: ["host-1"],
|
||||||
|
lockedWorkingDir: "/repo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("Missing root container");
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root!.render(<Probe draftKey="draft:setup" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
|
||||||
|
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getLatest().setText("hello world");
|
||||||
|
getLatest().setImages([image]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Probe draftKey="draft:setup" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLatest().text).toBe("hello world");
|
||||||
|
expect(getLatest().images).toEqual([image]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
||||||
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
|
const sentImage: AttachmentMetadata = {
|
||||||
|
id: "attachment-sent",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachments/sent",
|
||||||
|
createdAt: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error("Expected hook result");
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("Missing root container");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Probe />);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getLatest().setText("queued message");
|
||||||
|
getLatest().setImages([sentImage]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getLatest().clear("sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLatest().text).toBe("");
|
||||||
|
expect(getLatest().images).toEqual([]);
|
||||||
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||||
|
lifecycle: "sent",
|
||||||
|
input: { text: "", images: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getLatest().setText("draft again");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
getLatest().clear("abandoned");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||||
|
lifecycle: "abandoned",
|
||||||
|
input: { text: "", images: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
let __private__: typeof import("./use-agent-input-draft").__private__;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
value: {
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
({ __private__ } = await import("./use-agent-input-draft"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentInputDraft", () => {
|
||||||
|
describe("__private__.resolveDraftKey", () => {
|
||||||
|
it("returns an object draft key string unchanged", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveDraftKey({
|
||||||
|
draftKey: "draft:key",
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a computed draft key from the selected server", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveDraftKey({
|
||||||
|
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:host-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||||
|
const models = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4-mini",
|
||||||
|
label: "gpt-5.4-mini",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("prefers the selected model when present", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveEffectiveComposerModelId({
|
||||||
|
selectedModel: "gpt-5.4-mini",
|
||||||
|
availableModels: models,
|
||||||
|
}),
|
||||||
|
).toBe("gpt-5.4-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveEffectiveComposerModelId({
|
||||||
|
selectedModel: "",
|
||||||
|
availableModels: models,
|
||||||
|
}),
|
||||||
|
).toBe("gpt-5.4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||||
|
const models = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("prefers the selected thinking option when present", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: "medium",
|
||||||
|
availableModels: models,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the model default thinking option", () => {
|
||||||
|
expect(
|
||||||
|
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe("high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||||
|
it("returns undefined when cwd is empty", () => {
|
||||||
|
expect(
|
||||||
|
__private__.buildDraftComposerCommandConfig({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: " ",
|
||||||
|
modeOptions: [],
|
||||||
|
selectedMode: "",
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
effectiveThinkingOptionId: "high",
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the draft command config from derived composer state", () => {
|
||||||
|
expect(
|
||||||
|
__private__.buildDraftComposerCommandConfig({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
|
selectedMode: "auto",
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
effectiveThinkingOptionId: "high",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,44 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||||
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
|
import {
|
||||||
|
useAgentFormState,
|
||||||
|
type CreateAgentInitialValues,
|
||||||
|
type UseAgentFormStateResult,
|
||||||
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
||||||
|
|
||||||
|
type AgentInputDraftComposerOptions = {
|
||||||
|
initialServerId: string | null;
|
||||||
|
initialValues?: CreateAgentInitialValues;
|
||||||
|
isVisible?: boolean;
|
||||||
|
onlineServerIds?: string[];
|
||||||
|
lockedWorkingDir?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftKeyContext = {
|
||||||
|
selectedServerId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||||
|
|
||||||
|
type UseAgentInputDraftInput = {
|
||||||
|
draftKey: DraftKeyInput;
|
||||||
|
composer?: AgentInputDraftComposerOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftComposerState = UseAgentFormStateResult & {
|
||||||
|
workingDir: string;
|
||||||
|
effectiveModelId: string;
|
||||||
|
effectiveThinkingOptionId: string;
|
||||||
|
statusControls: DraftAgentStatusBarProps;
|
||||||
|
commandDraftConfig: DraftCommandConfig | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
interface AgentInputDraft {
|
interface AgentInputDraft {
|
||||||
text: string;
|
text: string;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
@@ -11,6 +46,7 @@ interface AgentInputDraft {
|
|||||||
setImages: (updater: ImageUpdater) => void;
|
setImages: (updater: ImageUpdater) => void;
|
||||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
||||||
@@ -36,7 +72,108 @@ function areImagesEqual(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
function resolveDraftKey(input: {
|
||||||
|
draftKey: DraftKeyInput;
|
||||||
|
selectedServerId: string | null;
|
||||||
|
}): string {
|
||||||
|
if (typeof input.draftKey === "function") {
|
||||||
|
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||||
|
}
|
||||||
|
return input.draftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveComposerModelId(input: {
|
||||||
|
selectedModel: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
}): string {
|
||||||
|
const selectedModel = input.selectedModel.trim();
|
||||||
|
if (selectedModel) {
|
||||||
|
return selectedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveComposerThinkingOptionId(input: {
|
||||||
|
selectedThinkingOptionId: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
effectiveModelId: string;
|
||||||
|
}): string {
|
||||||
|
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||||
|
if (selectedThinkingOptionId) {
|
||||||
|
return selectedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModelDefinition =
|
||||||
|
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftComposerCommandConfig(input: {
|
||||||
|
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||||
|
cwd: string;
|
||||||
|
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||||
|
selectedMode: string;
|
||||||
|
effectiveModelId: string;
|
||||||
|
effectiveThinkingOptionId: string;
|
||||||
|
}): DraftCommandConfig | undefined {
|
||||||
|
const cwd = input.cwd.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: input.provider,
|
||||||
|
cwd,
|
||||||
|
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
|
||||||
|
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||||
|
...(input.effectiveThinkingOptionId
|
||||||
|
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftStatusControls(input: {
|
||||||
|
formState: UseAgentFormStateResult;
|
||||||
|
}): DraftAgentStatusBarProps {
|
||||||
|
const { formState } = input;
|
||||||
|
return {
|
||||||
|
providerDefinitions: formState.providerDefinitions,
|
||||||
|
selectedProvider: formState.selectedProvider,
|
||||||
|
onSelectProvider: formState.setProviderFromUser,
|
||||||
|
modeOptions: formState.modeOptions,
|
||||||
|
selectedMode: formState.selectedMode,
|
||||||
|
onSelectMode: formState.setModeFromUser,
|
||||||
|
models: formState.availableModels,
|
||||||
|
selectedModel: formState.selectedModel,
|
||||||
|
onSelectModel: formState.setModelFromUser,
|
||||||
|
isModelLoading: formState.isModelLoading,
|
||||||
|
allProviderModels: formState.allProviderModels,
|
||||||
|
isAllModelsLoading: formState.isAllModelsLoading,
|
||||||
|
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||||
|
thinkingOptions: formState.availableThinkingOptions,
|
||||||
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||||
|
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||||
|
const composerOptions = input.composer ?? null;
|
||||||
|
const formState = useAgentFormState({
|
||||||
|
initialServerId: composerOptions?.initialServerId ?? null,
|
||||||
|
initialValues: composerOptions?.initialValues,
|
||||||
|
isVisible: composerOptions?.isVisible ?? false,
|
||||||
|
isCreateFlow: true,
|
||||||
|
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
||||||
|
});
|
||||||
|
const draftKey = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveDraftKey({
|
||||||
|
draftKey: input.draftKey,
|
||||||
|
selectedServerId: formState.selectedServerId,
|
||||||
|
}),
|
||||||
|
[formState.selectedServerId, input.draftKey],
|
||||||
|
);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
|||||||
});
|
});
|
||||||
}, [draftKey, images, text]);
|
}, [draftKey, images, text]);
|
||||||
|
|
||||||
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!composerOptions || !lockedWorkingDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formState.workingDir.trim() === lockedWorkingDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formState.setWorkingDir(lockedWorkingDir);
|
||||||
|
}, [composerOptions, formState, lockedWorkingDir]);
|
||||||
|
|
||||||
|
const effectiveModelId = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveEffectiveComposerModelId({
|
||||||
|
selectedModel: formState.selectedModel,
|
||||||
|
availableModels: formState.availableModels,
|
||||||
|
}),
|
||||||
|
[formState.availableModels, formState.selectedModel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveThinkingOptionId = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||||
|
availableModels: formState.availableModels,
|
||||||
|
effectiveModelId,
|
||||||
|
}),
|
||||||
|
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||||
|
|
||||||
|
const commandDraftConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
composerOptions
|
||||||
|
? buildDraftComposerCommandConfig({
|
||||||
|
provider: formState.selectedProvider,
|
||||||
|
cwd: workingDir,
|
||||||
|
modeOptions: formState.modeOptions,
|
||||||
|
selectedMode: formState.selectedMode,
|
||||||
|
effectiveModelId,
|
||||||
|
effectiveThinkingOptionId,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
[
|
||||||
|
composerOptions,
|
||||||
|
effectiveModelId,
|
||||||
|
effectiveThinkingOptionId,
|
||||||
|
workingDir,
|
||||||
|
formState.modeOptions,
|
||||||
|
formState.selectedMode,
|
||||||
|
formState.selectedProvider,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||||
|
if (!composerOptions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...formState,
|
||||||
|
workingDir,
|
||||||
|
effectiveModelId,
|
||||||
|
effectiveThinkingOptionId,
|
||||||
|
statusControls: buildDraftStatusControls({ formState }),
|
||||||
|
commandDraftConfig,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
commandDraftConfig,
|
||||||
|
composerOptions,
|
||||||
|
effectiveModelId,
|
||||||
|
effectiveThinkingOptionId,
|
||||||
|
formState,
|
||||||
|
workingDir,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
setText,
|
setText,
|
||||||
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
|||||||
setImages,
|
setImages,
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
|
composerState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __private__ = {
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
buildDraftStatusControls,
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id,
|
id,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
|
terminal: false,
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
|
||||||
|
export interface AgentScreenAgent {
|
||||||
|
serverId: string;
|
||||||
|
id: string;
|
||||||
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
|
cwd: string;
|
||||||
|
lastError?: string | null;
|
||||||
|
terminalExit?: {
|
||||||
|
command: string;
|
||||||
|
message: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: number | null;
|
||||||
|
outputLines: string[];
|
||||||
|
} | null;
|
||||||
|
projectPlacement?: {
|
||||||
|
checkout?: {
|
||||||
|
cwd?: string;
|
||||||
|
isGit?: boolean;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentScreenMissingState =
|
export type AgentScreenMissingState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
@@ -8,8 +28,8 @@ export type AgentScreenMissingState =
|
|||||||
| { kind: "error"; message: string };
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
export interface AgentScreenMachineInput {
|
export interface AgentScreenMachineInput {
|
||||||
agent: Agent | null;
|
agent: AgentScreenAgent | null;
|
||||||
placeholderAgent: Agent | null;
|
placeholderAgent: AgentScreenAgent | null;
|
||||||
missingAgentState: AgentScreenMissingState;
|
missingAgentState: AgentScreenMissingState;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
@@ -31,7 +51,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
|||||||
|
|
||||||
export interface AgentScreenMachineMemory {
|
export interface AgentScreenMachineMemory {
|
||||||
hasRenderedReady: boolean;
|
hasRenderedReady: boolean;
|
||||||
lastReadyAgent: Agent | null;
|
lastReadyAgent: AgentScreenAgent | null;
|
||||||
activeToastLatch: AgentScreenToastLatch;
|
activeToastLatch: AgentScreenToastLatch;
|
||||||
hadInitialSyncFailure: boolean;
|
hadInitialSyncFailure: boolean;
|
||||||
}
|
}
|
||||||
@@ -70,7 +90,7 @@ export type AgentScreenViewState =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
tag: "ready";
|
tag: "ready";
|
||||||
agent: Agent;
|
agent: AgentScreenAgent;
|
||||||
source: "authoritative" | "optimistic" | "stale";
|
source: "authoritative" | "optimistic" | "stale";
|
||||||
sync: AgentScreenReadySyncState;
|
sync: AgentScreenReadySyncState;
|
||||||
isArchiving: boolean;
|
isArchiving: boolean;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
|
|||||||
serverId,
|
serverId,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
|
terminal: agent.terminal,
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
lastActivityAt: agent.lastActivityAt,
|
lastActivityAt: agent.lastActivityAt,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id: input?.id ?? "agent-1",
|
id: input?.id ?? "agent-1",
|
||||||
provider: input?.provider ?? "codex",
|
provider: input?.provider ?? "codex",
|
||||||
|
terminal: input?.terminal ?? false,
|
||||||
status: input?.status ?? "idle",
|
status: input?.status ?? "idle",
|
||||||
createdAt: input?.createdAt ?? timestamp,
|
createdAt: input?.createdAt ?? timestamp,
|
||||||
updatedAt: input?.updatedAt ?? timestamp,
|
updatedAt: input?.updatedAt ?? timestamp,
|
||||||
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: input?.currentModeId ?? null,
|
currentModeId: input?.currentModeId ?? null,
|
||||||
availableModes: input?.availableModes ?? [],
|
availableModes: input?.availableModes ?? [],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
|
|||||||
serverId: params.serverId,
|
serverId: params.serverId,
|
||||||
serverLabel: params.serverLabel,
|
serverLabel: params.serverLabel,
|
||||||
title: source.title ?? null,
|
title: source.title ?? null,
|
||||||
|
terminal: source.terminal,
|
||||||
status: source.status,
|
status: source.status,
|
||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: source.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ type CreateRequestContext = {
|
|||||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
getPendingServerId: () => string | null;
|
getPendingServerId: () => string | null;
|
||||||
|
allowEmptyText?: boolean;
|
||||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||||
onCreateStart?: () => void;
|
onCreateStart?: () => void;
|
||||||
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
|||||||
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
|
allowEmptyText = false,
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
onBeforeSubmit,
|
onBeforeSubmit,
|
||||||
onCreateStart,
|
onCreateStart,
|
||||||
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
return EMPTY_STREAM_ITEMS;
|
return EMPTY_STREAM_ITEMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
|
||||||
|
return EMPTY_STREAM_ITEMS;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||||
|
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
if (!trimmedPrompt) {
|
if (!trimmedPrompt && !allowEmptyText) {
|
||||||
const error = new Error("Initial prompt is required");
|
const error = new Error("Initial prompt is required");
|
||||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
setPendingCreateAttempt,
|
setPendingCreateAttempt,
|
||||||
updatePendingAgentId,
|
updatePendingAgentId,
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
|
allowEmptyText,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,13 +10,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
|||||||
const providerPreferencesSchema = z.object({
|
const providerPreferencesSchema = z.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingByModel: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
workingDir: z.string().optional(),
|
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
|
||||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,12 +33,37 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
|||||||
export interface UseFormPreferencesReturn {
|
export interface UseFormPreferencesReturn {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
|
|
||||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||||
updateProviderPreferences: (
|
}
|
||||||
provider: AgentProvider,
|
|
||||||
updates: Partial<ProviderPreferences>,
|
export function mergeProviderPreferences(args: {
|
||||||
) => Promise<void>;
|
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 } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
@@ -54,13 +77,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
|
|
||||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||||
|
|
||||||
const getProviderPreferences = useCallback(
|
|
||||||
(provider: AgentProvider): ProviderPreferences | undefined => {
|
|
||||||
return preferences.providerPreferences?.[provider];
|
|
||||||
},
|
|
||||||
[preferences.providerPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePreferences = useCallback(
|
const updatePreferences = useCallback(
|
||||||
async (updates: Partial<FormPreferences>) => {
|
async (updates: Partial<FormPreferences>) => {
|
||||||
const prev =
|
const prev =
|
||||||
@@ -73,32 +89,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProviderPreferences = useCallback(
|
|
||||||
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
|
|
||||||
const prev =
|
|
||||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
|
||||||
DEFAULT_FORM_PREFERENCES;
|
|
||||||
const next: FormPreferences = {
|
|
||||||
...prev,
|
|
||||||
providerPreferences: {
|
|
||||||
...prev.providerPreferences,
|
|
||||||
[provider]: {
|
|
||||||
...prev.providerPreferences?.[provider],
|
|
||||||
...updates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
|
||||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preferences,
|
preferences,
|
||||||
isLoading: isPending,
|
isLoading: isPending,
|
||||||
getProviderPreferences,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user