mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
150 Commits
v0.1.43
...
add-websto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3876ffe61 | ||
|
|
8f136a1f92 | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
3397e6c589 | ||
|
|
c4cccf5bd2 | ||
|
|
0130a637c8 | ||
|
|
8c2ea33da8 | ||
|
|
0d88ce2270 | ||
|
|
6907f6e71d | ||
|
|
84638ecd9c | ||
|
|
809e5fbbdc | ||
|
|
a264058a30 | ||
|
|
702e2e7db9 | ||
|
|
c32bc847bf | ||
|
|
7a2d976081 | ||
|
|
2d9ca6983a | ||
|
|
e44e495481 | ||
|
|
bedf792616 | ||
|
|
5db9942125 | ||
|
|
a889dbcc28 | ||
|
|
5c1e869bc5 | ||
|
|
a693ff560c | ||
|
|
89baf7ff38 | ||
|
|
c65a851205 | ||
|
|
85acdbb05e | ||
|
|
4e32f9b7bd | ||
|
|
744ca7a2bc | ||
|
|
3110bae209 | ||
|
|
16efdb2c95 | ||
|
|
1e9b7f1157 | ||
|
|
018ebd5f29 | ||
|
|
4706d9e3bd | ||
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
c3313ff82a | ||
|
|
1182092b94 | ||
|
|
8d1fb45f73 | ||
|
|
8f7de021f4 | ||
|
|
65573499af | ||
|
|
9154f8fc4d | ||
|
|
3d3e327378 | ||
|
|
42cfed514c | ||
|
|
99114ddd11 | ||
|
|
e73c332e4f | ||
|
|
1594e41602 | ||
|
|
4896cfe970 | ||
|
|
dcffe46f90 | ||
|
|
7b4b42068c | ||
|
|
100502ae1e | ||
|
|
178b4cd618 | ||
|
|
9a6a8ce497 | ||
|
|
325ab500b3 | ||
|
|
60bbfdde25 | ||
|
|
71ce95de90 | ||
|
|
255c2665cc | ||
|
|
176942bbf1 | ||
|
|
1ae2f9280e | ||
|
|
49402b854f | ||
|
|
9476b3fc1d | ||
|
|
29037abd54 | ||
|
|
23ffec5072 | ||
|
|
1e64dd5509 | ||
|
|
cacbbf4053 | ||
|
|
4ca04fc661 | ||
|
|
f924d33076 | ||
|
|
763cfa9333 | ||
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 |
48
.github/workflows/desktop-release.yml
vendored
48
.github/workflows/desktop-release.yml
vendored
@@ -35,8 +35,43 @@ env:
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create GitHub release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||
else
|
||||
prerelease_flag=""
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
prerelease_flag="--prerelease"
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--generate-notes \
|
||||
$prerelease_flag
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -130,6 +165,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
@@ -214,7 +254,8 @@ jobs:
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -280,7 +321,8 @@ jobs:
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.50 - 2026-04-07
|
||||
|
||||
### Added
|
||||
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||
|
||||
### Improved
|
||||
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||
- Closing a workspace tab is now instant.
|
||||
- Settings shows a Refresh button for providers and displays error details inline.
|
||||
- Reload agent moved away from the close button to prevent accidental taps.
|
||||
|
||||
### Fixed
|
||||
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||
- Garbled overlapping text on plan cards.
|
||||
- Changes pane could show stale diffs when working with git worktrees.
|
||||
- Restarting an agent quickly could crash the session.
|
||||
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||
- Connection and pairing dialogs now display correctly on tablets.
|
||||
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||
- Diff stats no longer reset to zero when reconnecting.
|
||||
|
||||
## 0.1.49 - 2026-04-07
|
||||
|
||||
### Fixed
|
||||
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||
- Model lists stay consistent regardless of which screen you open first.
|
||||
|
||||
## 0.1.48 - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
||||
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
||||
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
||||
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
||||
|
||||
### Improved
|
||||
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
||||
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
||||
- Removed noisy agent lifecycle toasts that fired on every state change.
|
||||
|
||||
### Fixed
|
||||
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
||||
- Input field on running agent screens now correctly receives keyboard focus.
|
||||
- Mobile model selector alignment and sizing.
|
||||
|
||||
## 0.1.47 - 2026-04-05
|
||||
|
||||
### Fixed
|
||||
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
||||
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
||||
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
||||
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
|
||||
|
||||
## 0.1.46 - 2026-04-04
|
||||
|
||||
### Fixed
|
||||
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
||||
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
||||
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
||||
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
|
||||
|
||||
## 0.1.45 - 2026-04-04
|
||||
|
||||
### Added
|
||||
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
||||
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
||||
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
||||
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
||||
- Daemon status dialog in desktop settings for quick health checks.
|
||||
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
||||
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||
|
||||
### Improved
|
||||
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||
|
||||
### Fixed
|
||||
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||
- Pinned archived agents are no longer pruned when closing tabs.
|
||||
- OpenCode event stream starvation during slash command execution.
|
||||
- Duplicate workspaces when multiple git worktrees share the same root.
|
||||
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
||||
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
||||
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
||||
- Input event listener race condition in the web scrollbar hook.
|
||||
- Open-project screen content now vertically centered.
|
||||
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
||||
|
||||
## 0.1.44 - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||
- Disabled claude-acp and copilot providers from the agent registry.
|
||||
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
||||
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
||||
|
||||
## 0.1.43 - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -45,6 +45,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Always run typecheck after every change.**
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
20
SECURITY.md
20
SECURITY.md
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages** — Each session derives fresh encryption keys
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
|
||||
### Trust model
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||
|
||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
## DNS rebinding protection
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
206
docs/MOBILE_TESTING.md
Normal file
206
docs/MOBILE_TESTING.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Mobile Testing
|
||||
|
||||
## Maestro
|
||||
|
||||
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||
|
||||
Run a flow:
|
||||
|
||||
```bash
|
||||
maestro test packages/app/maestro/my-flow.yaml
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||
|
||||
```bash
|
||||
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||
mkdir -p /tmp/maestro-out
|
||||
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||
```
|
||||
|
||||
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||
|
||||
### Element targeting
|
||||
|
||||
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||
|
||||
```tsx
|
||||
// Component
|
||||
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Flow
|
||||
- tapOn:
|
||||
id: "sidebar-sessions"
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
```
|
||||
|
||||
### Conditional steps
|
||||
|
||||
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||
|
||||
```yaml
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
```
|
||||
|
||||
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||
|
||||
### Don't use launchApp against a running dev app
|
||||
|
||||
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||
|
||||
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||
|
||||
### Swipe gestures
|
||||
|
||||
Use `start`/`end` with percentage coordinates for precise control:
|
||||
|
||||
```yaml
|
||||
# Edge swipe from left to open sidebar
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
```
|
||||
|
||||
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||
|
||||
### Assertions
|
||||
|
||||
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||
|
||||
For async elements, use `extendedWaitUntil`:
|
||||
|
||||
```yaml
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Online.*"
|
||||
timeout: 90000
|
||||
```
|
||||
|
||||
### Dev client handling
|
||||
|
||||
Two reusable flows handle Expo dev client screens after launch:
|
||||
|
||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||
|
||||
## Self-verification loops
|
||||
|
||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||
|
||||
This pattern also lets agents self-verify fixes without manual user testing.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Run baseline Maestro flow (confirm feature works)
|
||||
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||
3. Re-run Maestro flow (confirm feature still works)
|
||||
4. Repeat N iterations to catch intermittent failures
|
||||
|
||||
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||
|
||||
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||
```
|
||||
|
||||
Key elements of the script pattern:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
ITERATIONS="${1:-3}"
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
# Toggle system state
|
||||
xcrun simctl ui booted appearance light
|
||||
|
||||
# Wait for change to propagate
|
||||
sleep 1
|
||||
|
||||
# Run Maestro flow and capture result
|
||||
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
|
||||
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||
|
||||
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||
|
||||
### The fix
|
||||
|
||||
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||
|
||||
```tsx
|
||||
// BAD: Unistyles dynamic style on Animated.View
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||
```
|
||||
|
||||
```tsx
|
||||
// GOOD: static stylesheet + inline theme values
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<Animated.View
|
||||
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
/>
|
||||
```
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
# Screenshot
|
||||
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||
|
||||
# Dark/light mode
|
||||
xcrun simctl ui booted appearance # check current
|
||||
xcrun simctl ui booted appearance dark # set dark
|
||||
xcrun simctl ui booted appearance light # set light
|
||||
```
|
||||
|
||||
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
@@ -122,8 +129,46 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
|
||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||
|
||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
|
||||
68
docs/plan-approval-normalization.md
Normal file
68
docs/plan-approval-normalization.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Plan Approval Normalization
|
||||
|
||||
## Goal
|
||||
|
||||
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||
|
||||
## Compatibility Constraints
|
||||
|
||||
- Older clients must remain compatible with newer daemons.
|
||||
- All new wire fields must be optional.
|
||||
- Existing plan permissions without action metadata must still render and work.
|
||||
- Existing question permissions must keep their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Shared abstraction
|
||||
|
||||
Add optional permission action definitions to the shared permission request/response types.
|
||||
|
||||
- Permission requests may include `actions`.
|
||||
- Permission responses may include `selectedActionId`.
|
||||
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||
|
||||
### Claude
|
||||
|
||||
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||
|
||||
- Always expose `Reject`.
|
||||
- Always expose `Implement`.
|
||||
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||
|
||||
### Codex
|
||||
|
||||
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||
|
||||
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||
- On `Reject`, resolve without starting a follow-up turn.
|
||||
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||
|
||||
### Manager and state sync
|
||||
|
||||
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||
|
||||
- Refresh current mode
|
||||
- Refresh pending permissions
|
||||
- Refresh runtime info
|
||||
- Refresh features
|
||||
- Persist refreshed state
|
||||
|
||||
### UI
|
||||
|
||||
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||
|
||||
- If `actions` are absent, fall back to legacy buttons.
|
||||
- Plan cards should use `Implement` as the default primary label.
|
||||
- Do not add provider-specific rendering branches.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||
2. App tests for generic plan-action rendering
|
||||
3. Claude tests for third action when resuming from a more permissive mode
|
||||
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||
5. Manager tests for post-permission state refresh
|
||||
6. `npm run typecheck`
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# 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-0fzdnz2LQ0IRk2wbe0/wORylp7mgU0gl2fAs8my4Eok=";
|
||||
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||
|
||||
# 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).
|
||||
|
||||
230
package-lock.json
generated
230
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -3519,6 +3519,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -15434,6 +15441,24 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
@@ -15907,18 +15932,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-shell": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
|
||||
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||
@@ -18520,35 +18533,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/execa/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/expect": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
||||
@@ -21834,18 +21818,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-symbol-description": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||
@@ -22557,15 +22529,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
@@ -23294,6 +23257,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -26533,6 +26497,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -27439,18 +27404,6 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -27676,6 +27629,7 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
@@ -28239,6 +28193,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pi-acp": {
|
||||
"version": "0.0.24",
|
||||
"resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
|
||||
"integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"bin": {
|
||||
"pi-acp": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-acp/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -31065,50 +31053,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz",
|
||||
"integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-shell": "^2.0.0",
|
||||
"execa": "^5.1.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
@@ -32041,15 +31985,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
||||
@@ -34971,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35097,11 +35032,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35142,11 +35077,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/cli": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35180,7 +35115,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35381,7 +35316,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35407,7 +35342,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35423,14 +35358,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35446,11 +35381,11 @@
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pi-acp": "^0.0.24",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"shell-env": "^4.0.3",
|
||||
"sherpa-onnx": "1.12.28",
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
@@ -35468,6 +35403,7 @@
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.8",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"playwright": "^1.56.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
@@ -35828,7 +35764,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
2
packages/app/maestro/.gitignore
vendored
Normal file
2
packages/app/maestro/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Maestro takeScreenshot artifacts
|
||||
*.png
|
||||
31
packages/app/maestro/sidebar-theme-repro.yaml
Normal file
31
packages/app/maestro/sidebar-theme-repro.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
appId: sh.paseo
|
||||
---
|
||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
|
||||
# Small pause for close animation
|
||||
- takeScreenshot: 00-sidebar-closed
|
||||
|
||||
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
|
||||
# Verify sidebar opened
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
- takeScreenshot: 01-sidebar-opened
|
||||
|
||||
# Close sidebar via swipe left
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
- takeScreenshot: 02-sidebar-closed-again
|
||||
62
packages/app/maestro/test-sidebar-theme.sh
Executable file
62
packages/app/maestro/test-sidebar-theme.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verification loop for sidebar theme bug.
|
||||
#
|
||||
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
||||
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
||||
# still works. Runs N iterations to catch intermittent failures.
|
||||
#
|
||||
# Usage:
|
||||
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
||||
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
||||
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
||||
ITERATIONS="${1:-3}"
|
||||
WAIT_SECS="${2:-1}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "=== Sidebar Theme Bug Verification ==="
|
||||
echo "Output dir: $OUT_DIR"
|
||||
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
||||
|
||||
FAILURES=0
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
echo ""
|
||||
echo "========== Iteration $i / $ITERATIONS =========="
|
||||
|
||||
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
||||
echo "Current appearance: $CURRENT"
|
||||
|
||||
if [ "$CURRENT" = "dark" ]; then
|
||||
xcrun simctl ui booted appearance light
|
||||
echo "Switched to light mode"
|
||||
else
|
||||
xcrun simctl ui booted appearance dark
|
||||
echo "Switched to dark mode"
|
||||
fi
|
||||
|
||||
echo "Waiting ${WAIT_SECS}s..."
|
||||
sleep "$WAIT_SECS"
|
||||
|
||||
ITER_DIR="$OUT_DIR/iter-$i"
|
||||
mkdir -p "$ITER_DIR"
|
||||
|
||||
# Run maestro from the output dir so takeScreenshot artifacts land there
|
||||
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||
echo " -> PASS (iteration $i)"
|
||||
else
|
||||
echo " -> FAIL (iteration $i) — bug reproduced!"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore to dark mode
|
||||
xcrun simctl ui booted appearance dark
|
||||
|
||||
echo ""
|
||||
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
||||
echo "Output: $OUT_DIR"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
@@ -230,6 +232,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cancelAnyOnline: (() => void) | null = null;
|
||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
@@ -240,28 +243,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
if (isDesktopManaged) {
|
||||
setPhase("starting-daemon");
|
||||
setError(null);
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
if (!cancelled) {
|
||||
setPhase("error");
|
||||
setError(bootstrapResult.error);
|
||||
|
||||
let raceSettled = false;
|
||||
|
||||
const anyOnline = store.waitForAnyConnectionOnline();
|
||||
cancelAnyOnline = anyOnline.cancel;
|
||||
|
||||
const bootstrapPromise = (async (): Promise<
|
||||
{ type: "online" } | { type: "error"; error: string }
|
||||
> => {
|
||||
try {
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
return { type: "error", error: bootstrapResult.error };
|
||||
}
|
||||
if (!cancelled && !raceSettled) {
|
||||
setPhase("connecting");
|
||||
}
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
return { type: "online" };
|
||||
} catch (err) {
|
||||
return {
|
||||
type: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const result = await Promise.race([
|
||||
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
||||
bootstrapPromise,
|
||||
]);
|
||||
|
||||
raceSettled = true;
|
||||
anyOnline.cancel();
|
||||
|
||||
setPhase("connecting");
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
if (result.type === "online") {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setError(result.error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
@@ -288,6 +316,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnyOnline?.();
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
@@ -340,10 +369,41 @@ function AppContainer({
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const bp = UnistylesRuntime.breakpoint;
|
||||
const screenW = UnistylesRuntime.screen.width;
|
||||
const screenH = UnistylesRuntime.screen.height;
|
||||
const isElectron = getIsElectronRuntime();
|
||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
||||
|
||||
console.log(
|
||||
"[layout-debug]",
|
||||
JSON.stringify({
|
||||
breakpoint: bp,
|
||||
isCompactLayout,
|
||||
isElectron,
|
||||
chromeEnabled,
|
||||
isFocusModeEnabled,
|
||||
agentListOpen,
|
||||
sidebarWidth,
|
||||
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||
unistylesScreen: { w: screenW, h: screenH },
|
||||
window: { w: windowW, h: windowH },
|
||||
devicePixelRatio: dpr,
|
||||
userAgent: ua,
|
||||
}),
|
||||
);
|
||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile: isCompactLayout,
|
||||
@@ -393,14 +453,28 @@ function MobileGestureWrapper({
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useSidebarAnimation();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
openAgentList();
|
||||
}, [openAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
.failOffsetY([-10, 10])
|
||||
@@ -443,7 +517,7 @@ function MobileGestureWrapper({
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(openAgentList)();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
@@ -458,8 +532,9 @@ function MobileGestureWrapper({
|
||||
backdropOpacity,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
openAgentList,
|
||||
handleGestureOpen,
|
||||
isGesturing,
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
],
|
||||
@@ -530,7 +605,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as any)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -555,6 +630,84 @@ function OfferLinkListener({
|
||||
return null;
|
||||
}
|
||||
|
||||
interface OpenProjectEventPayload {
|
||||
path?: unknown;
|
||||
}
|
||||
|
||||
function OpenProjectListener() {
|
||||
const hosts = useHosts();
|
||||
const serverId = hosts[0]?.serverId ?? null;
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const openProject = useOpenProject(serverId);
|
||||
const pendingPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const maybeOpenProject = (inputPath: string) => {
|
||||
const nextPath = inputPath.trim();
|
||||
if (!nextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPathRef.current = nextPath;
|
||||
|
||||
if (!serverId || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathToOpen = pendingPathRef.current;
|
||||
pendingPathRef.current = null;
|
||||
if (!pathToOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void openProject(pathToOpen).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Pull any path that was passed on cold start (before the listener existed).
|
||||
// Store in the ref even if this effect instance is disposed — the next
|
||||
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||
void getDesktopHost()
|
||||
?.getPendingOpenProject?.()
|
||||
?.then((pending) => {
|
||||
if (pending) {
|
||||
pendingPathRef.current = pending;
|
||||
}
|
||||
if (!disposed && pending) {
|
||||
maybeOpenProject(pending);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Listen for hot-start paths relayed via the second-instance event.
|
||||
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
||||
maybeOpenProject(nextPath);
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unlisten = dispose;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
maybeOpenProject(pendingPathRef.current ?? "");
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [client, openProject, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -570,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
@@ -674,6 +827,7 @@ export default function RootLayout() {
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||
|
||||
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||
router.replace(buildHostOpenProjectRoute(serverId));
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function Index() {
|
||||
const targetRoute = anyOnlineServerId
|
||||
? buildHostRootRoute(anyOnlineServerId)
|
||||
: WELCOME_ROUTE;
|
||||
router.replace(targetRoute as any);
|
||||
router.replace(targetRoute);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function PairScanScreen() {
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
}
|
||||
if (source === "editHost" && targetServerId) {
|
||||
@@ -190,7 +190,7 @@ export default function PairScanScreen() {
|
||||
router.back();
|
||||
} catch {
|
||||
const settingsServerId = sourceServerId ?? serverId;
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(settingsServerId));
|
||||
}
|
||||
},
|
||||
[router, source, sourceServerId, targetServerId],
|
||||
@@ -209,7 +209,7 @@ export default function PairScanScreen() {
|
||||
router.back();
|
||||
} catch {
|
||||
if (sourceServerId) {
|
||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(targetServerId));
|
||||
}, [router, targetServerId]);
|
||||
|
||||
if (!targetServerId) {
|
||||
|
||||
@@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DraftAgentStatusBar,
|
||||
type DraftAgentStatusBarProps,
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -74,6 +75,8 @@ interface AgentInputAreaProps {
|
||||
autoFocus?: boolean;
|
||||
/** Callback to expose the addImages function to parent components */
|
||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig;
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
@@ -103,6 +106,7 @@ export function AgentInputArea({
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
onAddImages,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
@@ -132,6 +136,8 @@ export function AgentInputArea({
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -208,6 +214,21 @@ export function AgentInputArea({
|
||||
onAddImages?.(addImages);
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
||||
return el != null && document.activeElement === el;
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onFocusInput?.(focusInput);
|
||||
}, [focusInput, onFocusInput]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
async (text: string, images?: ImageAttachment[]) => {
|
||||
onMessageSent?.();
|
||||
@@ -402,6 +423,10 @@ export function AgentInputArea({
|
||||
}
|
||||
|
||||
switch (action.id) {
|
||||
case "message-input.send":
|
||||
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
||||
case "message-input.dictation-confirm":
|
||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||
case "message-input.focus":
|
||||
if (Platform.OS !== "web") {
|
||||
messageInputRef.current?.focus();
|
||||
@@ -440,8 +465,10 @@ export function AgentInputArea({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: [
|
||||
"message-input.focus",
|
||||
"message-input.send",
|
||||
"message-input.dictation-toggle",
|
||||
"message-input.dictation-cancel",
|
||||
"message-input.dictation-confirm",
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
@@ -612,11 +639,30 @@ export function AgentInputArea({
|
||||
</View>
|
||||
);
|
||||
|
||||
const hasContextWindowMeter =
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
typeof agentState.contextWindowUsedTokens === "number";
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter
|
||||
? agentState.contextWindowUsedTokens
|
||||
: null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
) : (
|
||||
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -691,6 +737,7 @@ export function AgentInputArea({
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -766,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -240,7 +240,7 @@ export function AgentList({
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature metadata helpers", () => {
|
||||
it("prefers explicit feature tooltip copy", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Plan",
|
||||
tooltip: "Toggle plan mode",
|
||||
}),
|
||||
).toBe("Toggle plan mode");
|
||||
});
|
||||
|
||||
it("falls back to the feature label when no tooltip is provided", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Custom",
|
||||
}),
|
||||
).toBe("Custom");
|
||||
});
|
||||
|
||||
it("maps feature highlight colors by feature id", () => {
|
||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
|
||||
@@ -3,11 +3,20 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||
import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ListTodo,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -24,6 +33,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
@@ -36,17 +46,18 @@ import {
|
||||
type AgentModeIcon,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
@@ -63,6 +74,7 @@ type ControlledAgentStatusBarProps = {
|
||||
modelOptions?: StatusOption[];
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||
thinkingOptions?: StatusOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
@@ -73,6 +85,9 @@ type ControlledAgentStatusBarProps = {
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -92,12 +107,16 @@ export interface DraftAgentStatusBarProps {
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
selectedThinkingOptionId: string;
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
onDropdownClose?: () => void;
|
||||
}
|
||||
|
||||
function findOptionLabel(
|
||||
@@ -112,6 +131,38 @@ function findOptionLabel(
|
||||
return selected?.label ?? fallback;
|
||||
}
|
||||
|
||||
const FEATURE_ICONS: Record<string, typeof Zap> = {
|
||||
"list-todo": ListTodo,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
function getFeatureIcon(icon?: string) {
|
||||
return (icon && FEATURE_ICONS[icon]) || Settings2;
|
||||
}
|
||||
|
||||
function getFeatureIconColor(
|
||||
featureId: string,
|
||||
enabled: boolean,
|
||||
palette: {
|
||||
blue: { 400: string };
|
||||
yellow: { 400: string };
|
||||
},
|
||||
foregroundMuted: string,
|
||||
): string {
|
||||
if (!enabled) {
|
||||
return foregroundMuted;
|
||||
}
|
||||
|
||||
switch (getFeatureHighlightColor(featureId)) {
|
||||
case "blue":
|
||||
return palette.blue[400];
|
||||
case "yellow":
|
||||
return palette.yellow[400];
|
||||
default:
|
||||
return foregroundMuted;
|
||||
}
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
@@ -152,6 +203,7 @@ function ControlledStatusBar({
|
||||
modelOptions,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
@@ -162,6 +214,9 @@ function ControlledStatusBar({
|
||||
canSelectModelProvider,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavoriteModel,
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -203,13 +258,14 @@ function ControlledStatusBar({
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
canSelectModel ||
|
||||
Boolean(thinkingOptions?.length);
|
||||
Boolean(thinkingOptions?.length) ||
|
||||
Boolean(features?.length);
|
||||
|
||||
if (!hasAnyControl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
||||
const modelDisabled = disabled;
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
@@ -280,8 +336,11 @@ function ControlledStatusBar({
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null);
|
||||
if (!nextOpen) {
|
||||
onDropdownClose?.();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[onDropdownClose],
|
||||
);
|
||||
|
||||
const handleSelectorPress = useCallback(
|
||||
@@ -352,6 +411,7 @@ function ControlledStatusBar({
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
@@ -455,6 +515,105 @@ function ControlledStatusBar({
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{features?.map((feature) => {
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`feature-${feature.id}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
pressed && styles.modeBadgePressed,
|
||||
disabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
/>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (feature.type === "select") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
return (
|
||||
<DropdownMenu
|
||||
key={`feature-${feature.id}`}
|
||||
open={openSelector === `feature-${feature.id}`}
|
||||
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === `feature-${feature.id}`) &&
|
||||
styles.modeBadgePressed,
|
||||
disabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{selectedOption?.label ?? feature.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{feature.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
selected={option.id === feature.value}
|
||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -490,15 +649,20 @@ function ControlledStatusBar({
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
if (onSelectProviderAndModel) {
|
||||
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||
} else {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onClose={onDropdownClose}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
style={[
|
||||
@@ -508,6 +672,7 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -533,6 +698,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-preferences-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -593,6 +759,83 @@ function ControlledStatusBar({
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{features?.map((feature) => {
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
return (
|
||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
disabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
/>
|
||||
<Text style={styles.sheetSelectText}>{feature.label}</Text>
|
||||
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (feature.type === "select") {
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
return (
|
||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === `feature-${feature.id}`}
|
||||
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
disabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>
|
||||
{selectedOption?.label ?? feature.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{feature.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
selected={option.id === feature.value}
|
||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
)}
|
||||
@@ -602,7 +845,7 @@ function ControlledStatusBar({
|
||||
|
||||
const EMPTY_MODES: AgentMode[] = [];
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
@@ -614,7 +857,9 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
currentModeId: currentAgent.currentModeId,
|
||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
@@ -626,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
|
||||
const agentProviderModelQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -782,7 +1009,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
features={agent.features}
|
||||
onSetFeature={(featureId, value) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
@@ -805,6 +1042,9 @@ export function DraftAgentStatusBar({
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -849,6 +1089,7 @@ export function DraftAgentStatusBar({
|
||||
}}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
@@ -858,6 +1099,9 @@ export function DraftAgentStatusBar({
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
@@ -870,28 +1114,33 @@ export function DraftAgentStatusBar({
|
||||
}));
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||
switch (featureId) {
|
||||
case "fast_mode":
|
||||
return "yellow";
|
||||
case "plan_mode":
|
||||
return "blue";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
models: AgentModelDefinition[] | null;
|
||||
runtimeModelId: string | null | undefined;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -27,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
UserMessage,
|
||||
ActivityLog,
|
||||
ToolCall,
|
||||
@@ -36,9 +36,13 @@ import {
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
} from "./message";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -59,9 +63,7 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -180,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,10 +254,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
if (
|
||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||
isToolSequenceItem(belowItem)
|
||||
) {
|
||||
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||
@@ -269,44 +268,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[looseGap, tightGap],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEBUG: track when render callback deps change
|
||||
// ---------------------------------------------------------------------------
|
||||
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
|
||||
useEffect(() => {
|
||||
const prev = debugStreamPrevRef.current;
|
||||
const curr: Record<string, unknown> = {
|
||||
// handleInlinePathPress deps (line 196-205)
|
||||
"hip.agent.cwd": agent.cwd,
|
||||
"hip.openFileExplorer": openFileExplorer,
|
||||
"hip.requestDirectoryListing": requestDirectoryListing,
|
||||
"hip.resolvedServerId": resolvedServerId,
|
||||
"hip.router": router,
|
||||
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
|
||||
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
|
||||
"hip.workspaceId": workspaceId,
|
||||
// top-level deps
|
||||
handleInlinePathPress,
|
||||
"agent.status": agent.status,
|
||||
streamRenderStrategy,
|
||||
getGapBetween,
|
||||
streamItems,
|
||||
"streamItems.length": streamItems.length,
|
||||
streamHead,
|
||||
baseRenderModel,
|
||||
};
|
||||
const changed: string[] = [];
|
||||
for (const key of Object.keys(curr)) {
|
||||
if (!Object.is(prev[key], curr[key])) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
if (changed.length > 0 && Object.keys(prev).length > 0) {
|
||||
console.log("[AgentStreamView] deps changed:", changed.join(", "));
|
||||
}
|
||||
debugStreamPrevRef.current = curr;
|
||||
});
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(
|
||||
item: StreamItem,
|
||||
@@ -368,7 +329,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
|
||||
case "thought": {
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
@@ -400,6 +360,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
|
||||
if (
|
||||
data.name === "speak" &&
|
||||
data.detail.type === "unknown" &&
|
||||
typeof data.detail.input === "string" &&
|
||||
data.detail.input.trim()
|
||||
) {
|
||||
return (
|
||||
<SpeakMessage
|
||||
message={data.detail.input}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.name}
|
||||
@@ -738,6 +713,29 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||
return request.actions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -755,90 +753,6 @@ function PermissionRequestCard({
|
||||
return undefined;
|
||||
}, [request]);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
text: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
textgroup: (
|
||||
node: any,
|
||||
children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
code_block: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
fence: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const permissionMutation = useMutation({
|
||||
mutationFn: async (input: {
|
||||
agentId: string;
|
||||
@@ -862,11 +776,11 @@ function PermissionRequestCard({
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
setRespondingActionId(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
@@ -880,6 +794,24 @@ function PermissionRequestCard({
|
||||
},
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
const handleActionPress = useCallback(
|
||||
(action: AgentPermissionAction) => {
|
||||
setRespondingActionId(action.id);
|
||||
if (action.behavior === "allow") {
|
||||
handleResponse({
|
||||
behavior: "allow",
|
||||
selectedActionId: action.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
selectedActionId: action.id,
|
||||
message: "Denied by user",
|
||||
});
|
||||
},
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
@@ -891,6 +823,79 @@ function PermissionRequestCard({
|
||||
);
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Text
|
||||
testID="permission-request-question"
|
||||
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
||||
>
|
||||
How would you like to proceed?
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
permissionStyles.optionsContainer,
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
{resolvedActions.map((action) => {
|
||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||
const isPrimary = action.variant === "primary";
|
||||
const isRespondingAction = respondingActionId === action.id;
|
||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = textColor;
|
||||
const Icon = action.behavior === "allow" ? Check : X;
|
||||
const testID =
|
||||
action.behavior === "deny"
|
||||
? "permission-request-deny"
|
||||
: action.id === "accept" || action.id === "implement"
|
||||
? "permission-request-accept"
|
||||
: `permission-request-action-${action.id}`;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
testID={testID}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ActivityIndicator size="small" color={textColor} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} color={iconColor} />
|
||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPlanRequest && planMarkdown) {
|
||||
return (
|
||||
<PlanCard
|
||||
title={title}
|
||||
description={description}
|
||||
text={planMarkdown}
|
||||
footer={footer}
|
||||
disableOuterSpacing
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -909,18 +914,7 @@ function PermissionRequestCard({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{planMarkdown ? (
|
||||
<View style={permissionStyles.section}>
|
||||
{!isPlanRequest ? (
|
||||
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
|
||||
Proposed plan
|
||||
</Text>
|
||||
) : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{planMarkdown}
|
||||
</Markdown>
|
||||
</View>
|
||||
) : null}
|
||||
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
|
||||
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
@@ -935,78 +929,7 @@ function PermissionRequestCard({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text
|
||||
testID="permission-request-question"
|
||||
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
||||
>
|
||||
How would you like to proceed?
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
permissionStyles.optionsContainer,
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Check size={14} color={theme.colors.foreground} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
{footer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -15,10 +17,14 @@ import {
|
||||
Search,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
@@ -29,7 +35,8 @@ import {
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
@@ -51,6 +58,7 @@ interface CombinedModelSelectorProps {
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,7 +75,6 @@ interface SelectorContentProps {
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
@@ -99,6 +106,22 @@ function partitionRows(
|
||||
return { favoriteRows, regularRows };
|
||||
}
|
||||
|
||||
function sortFavoritesFirst(
|
||||
rows: SelectorModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): SelectorModelRow[] {
|
||||
const favorites: SelectorModelRow[] = [];
|
||||
const rest: SelectorModelRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favorites.push(row);
|
||||
} else {
|
||||
rest.push(row);
|
||||
}
|
||||
}
|
||||
return [...favorites, ...rest];
|
||||
}
|
||||
|
||||
function groupRowsByProvider(
|
||||
rows: SelectorModelRow[],
|
||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||
@@ -126,6 +149,7 @@ function ModelRow({
|
||||
isSelected,
|
||||
isFavorite,
|
||||
disabled = false,
|
||||
elevated = false,
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
@@ -133,12 +157,12 @@ function ModelRow({
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
elevated?: boolean;
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -148,13 +172,18 @@ function ModelRow({
|
||||
[onToggleFavorite, row.modelId, row.provider],
|
||||
);
|
||||
|
||||
const item = (
|
||||
const showDescription =
|
||||
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
elevated={elevated}
|
||||
onPress={onPress}
|
||||
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
|
||||
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
trailingSlot={
|
||||
onToggleFavorite && !disabled ? (
|
||||
<Pressable
|
||||
@@ -187,21 +216,6 @@ function ModelRow({
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isWeb || !row.description) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>{item}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={4}>
|
||||
<Text style={styles.tooltipText}>{row.description}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function FavoritesSection({
|
||||
@@ -228,7 +242,7 @@ function FavoritesSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.favoritesContainer}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||
</View>
|
||||
@@ -239,11 +253,11 @@ function FavoritesSection({
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
elevated
|
||||
onPress={() => onSelect(row.provider, row.modelId)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -258,6 +272,7 @@ function GroupedProviderRows({
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
viewKind,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||
@@ -268,6 +283,7 @@ function GroupedProviderRows({
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
viewKind: SelectorView["kind"];
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -276,19 +292,14 @@ function GroupedProviderRows({
|
||||
{groupedRows.map((group, index) => {
|
||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||
const ProvIcon = getProviderIcon(group.providerId);
|
||||
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
|
||||
const isInline = viewKind === "provider";
|
||||
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>
|
||||
{providerDefinition?.label ?? group.providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{group.rows.map((row) => (
|
||||
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
||||
<ModelRow
|
||||
key={row.favoriteKey}
|
||||
row={row}
|
||||
@@ -309,11 +320,13 @@ function GroupedProviderRows({
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownCount}>
|
||||
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
|
||||
</Text>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -324,6 +337,46 @@ function GroupedProviderRows({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
return (
|
||||
<View style={styles.providerSearchContainer}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<InputComponent
|
||||
ref={inputRef as any}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
||||
placeholder="Search models..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providerDefinitions,
|
||||
@@ -337,8 +390,8 @@ function SelectorContent({
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
onBack,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const allRows = useMemo(
|
||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||
[allProviderModels, providerDefinitions],
|
||||
@@ -363,35 +416,41 @@ function SelectorContent({
|
||||
[favoriteKeys, visibleRows],
|
||||
);
|
||||
|
||||
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
|
||||
// Group ALL visible rows by provider — favorites are a cross-cutting view,
|
||||
// not a partition. A model being favorited doesn't remove it from its provider.
|
||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
||||
|
||||
// When searching at Level 1, filter grouped rows to only providers whose name or models match
|
||||
const filteredGroupedRows = useMemo(() => {
|
||||
if (view.kind === "provider" || !normalizedQuery) {
|
||||
return allGroupedRows;
|
||||
}
|
||||
return allGroupedRows.filter(
|
||||
(group) =>
|
||||
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
|
||||
);
|
||||
}, [allGroupedRows, normalizedQuery, view.kind]);
|
||||
|
||||
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{view.kind === "provider" ? (
|
||||
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
|
||||
{view.kind === "all" ? (
|
||||
<FavoritesSection
|
||||
favoriteRows={favoriteRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SearchInput
|
||||
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
|
||||
<FavoritesSection
|
||||
favoriteRows={favoriteRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
|
||||
{groupedRegularRows.length > 0 ? (
|
||||
{filteredGroupedRows.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providerDefinitions={providerDefinitions}
|
||||
groupedRows={groupedRegularRows}
|
||||
groupedRows={filteredGroupedRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -399,12 +458,13 @@ function SelectorContent({
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={onDrillDown}
|
||||
viewKind={view.kind}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
|
||||
{!hasResults ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={16} color="#777" />
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -437,8 +497,8 @@ function ProviderBackButton({
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -455,6 +515,7 @@ export function CombinedModelSelector({
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
onClose,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -465,25 +526,35 @@ export function CombinedModelSelector({
|
||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
const providers = Array.from(allProviderModels.keys());
|
||||
if (providers.length !== 1) return null;
|
||||
const providerId = providers[0]!;
|
||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [allProviderModels, providerDefinitions]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
setView({ kind: "all" });
|
||||
setView(singleProviderView ?? { kind: "all" });
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[onClose, singleProviderView],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
setIsOpen(false);
|
||||
setView({ kind: "all" });
|
||||
setView(singleProviderView ?? { kind: "all" });
|
||||
setSearchQuery("");
|
||||
},
|
||||
[onSelect],
|
||||
[onSelect, singleProviderView],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
@@ -501,6 +572,15 @@ export function CombinedModelSelector({
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
return undefined;
|
||||
}
|
||||
const models = allProviderModels.get(view.providerId);
|
||||
const modelCount = models?.length ?? 0;
|
||||
return Math.min(80 + modelCount * 40, 400);
|
||||
}, [allProviderModels, view]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
return selectedModelLabel;
|
||||
@@ -568,7 +648,30 @@ export function CombinedModelSelector({
|
||||
stackBehavior="push"
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
desktopFixedHeight={desktopFixedHeight}
|
||||
title="Select model"
|
||||
stickyHeader={
|
||||
view.kind === "provider" ? (
|
||||
<View style={styles.level2Header}>
|
||||
{!singleProviderView ? (
|
||||
<ProviderBackButton
|
||||
providerId={view.providerId}
|
||||
providerLabel={view.providerLabel}
|
||||
onBack={() => {
|
||||
setView({ kind: "all" });
|
||||
setSearchQuery("");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ProviderSearchInput
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
</View>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isContentReady ? (
|
||||
<SelectorContent
|
||||
@@ -586,13 +689,6 @@ export function CombinedModelSelector({
|
||||
onDrillDown={(providerId, providerLabel) => {
|
||||
setView({ kind: "provider", providerId, providerLabel });
|
||||
}}
|
||||
onBack={
|
||||
view.kind === "provider"
|
||||
? () => {
|
||||
setView({ kind: "all" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
@@ -634,17 +730,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
favoritesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
sectionHeadingText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -658,6 +760,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
drillDownRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -667,8 +770,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
drillDownText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
drillDownTrailing: {
|
||||
flexDirection: "row",
|
||||
@@ -679,6 +782,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
level2Header: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -687,9 +795,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
backButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -720,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
favoriteButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sheetLoadingState: {
|
||||
minHeight: 160,
|
||||
justifyContent: "center",
|
||||
@@ -734,4 +839,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
providerSearchContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
providerSearchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
153
packages/app/src/components/context-window-meter.tsx
Normal file
153
packages/app/src/components/context-window-meter.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type ContextWindowMeterProps = {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidUsedTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||
return null;
|
||||
}
|
||||
return (usedTokens / maxTokens) * 100;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
|
||||
});
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)("swipe-close-gesture");
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
|
||||
backdropOpacity,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleClose,
|
||||
handleCloseFromGesture,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable
|
||||
style={styles.backdropPressable}
|
||||
onPress={() => handleClose("backdrop-press")}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top },
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
]}
|
||||
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("desktop-close-button")}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("desktop-close-button")}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -400,24 +410,28 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebarBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
}, [workspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestors.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const nextExpanded = !isExpanded;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.add(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
directories,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
49
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
49
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(
|
||||
editorId: EditorTargetId,
|
||||
): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
}
|
||||
|
||||
export function EditorAppIcon({
|
||||
editorId,
|
||||
size = 16,
|
||||
color,
|
||||
}: EditorAppIconProps) {
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={EDITOR_APP_IMAGES[editorId]}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
packages/app/src/components/icons/pi-icon.tsx
Normal file
19
packages/app/src/components/icons/pi-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface PiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
|
||||
<Path
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
fill={color}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -222,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
return;
|
||||
}
|
||||
closeToAgent();
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
router.push(buildHostSettingsRoute(activeServerId));
|
||||
}, [activeServerId, closeToAgent]);
|
||||
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
router.push(buildHostSettingsRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSessionsRoute(activeServerId) as any);
|
||||
router.push(buildHostSessionsRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
@@ -246,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||
setIsHostPickerOpen(false);
|
||||
router.push(nextPath as any);
|
||||
router.push(nextPath);
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
@@ -370,14 +377,16 @@ function MobileSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
@@ -452,7 +461,7 @@ function MobileSidebar({
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
@@ -471,7 +480,7 @@ function MobileSidebar({
|
||||
backdropOpacity,
|
||||
animateToClose,
|
||||
animateToOpen,
|
||||
handleClose,
|
||||
handleCloseFromGesture,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -498,13 +507,11 @@ function MobileSidebar({
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
||||
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -526,7 +533,7 @@ function MobileSidebar({
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
onWorkspacePress={() => closeToAgent()}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
@@ -688,7 +695,8 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
@@ -796,32 +804,37 @@ function DesktopSidebar({
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
desktopSidebarBorder: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface MessageInputProps {
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
beforeVoiceContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
rightContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -100,7 +102,7 @@ export interface MessageInputProps {
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
|
||||
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
|
||||
/**
|
||||
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||
* May return null if not mounted or on native.
|
||||
@@ -201,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -242,26 +245,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
runKeyboardAction: (action) => {
|
||||
if (action === "focus") {
|
||||
textInputRef.current?.focus();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "send" || action === "dictation-confirm") {
|
||||
if (isDictatingRef.current) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === "voice-toggle") {
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (isRealtimeVoiceForCurrentAgent) {
|
||||
voice?.toggleMute();
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "dictation-cancel") {
|
||||
if (isDictatingRef.current) {
|
||||
cancelDictation();
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "dictation-toggle") {
|
||||
@@ -271,7 +283,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
} else {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getNativeElement: () => {
|
||||
if (!IS_WEB) return null;
|
||||
@@ -1003,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Copy,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
@@ -71,6 +72,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -716,13 +718,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
workspaceRoot,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
|
||||
console.log("[AssistantMessage] render", {
|
||||
messageLength: message?.length,
|
||||
timestamp,
|
||||
hasOnInlinePathPress: !!onInlinePathPress,
|
||||
});
|
||||
|
||||
const { theme, rt } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
@@ -919,6 +914,65 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
});
|
||||
|
||||
interface SpeakMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SpeakMessage = memo(function SpeakMessage({
|
||||
message,
|
||||
timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="speak-message"
|
||||
style={[
|
||||
speakMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<MicVocal size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityLogProps {
|
||||
type: "system" | "info" | "success" | "error" | "artifact";
|
||||
message: string;
|
||||
@@ -1764,9 +1818,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
onInlineDetailsHoverChange,
|
||||
onInlineDetailsExpandedChange,
|
||||
}: ToolCallProps) {
|
||||
// DEBUG: log when ToolCall actually renders (inside memo boundary)
|
||||
console.log("[ToolCall] render", { toolName, status });
|
||||
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -1884,6 +1935,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
|
||||
162
packages/app/src/components/plan-card.tsx
Normal file
162
packages/app/src/components/plan-card.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
return {
|
||||
text: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
textgroup: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
code_block: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
fence: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanCard({
|
||||
title = "Plan",
|
||||
description,
|
||||
text,
|
||||
footer,
|
||||
disableOuterSpacing = false,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
text: string;
|
||||
footer?: ReactNode;
|
||||
disableOuterSpacing?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const markdownStyles = createMarkdownStyles(theme);
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
disableOuterSpacing && styles.containerCompact,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{text}
|
||||
</Markdown>
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginVertical: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
containerCompact: {
|
||||
marginVertical: 0,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
description: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
102
packages/app/src/components/provider-diagnostic-sheet.tsx
Normal file
102
packages/app/src/components/provider-diagnostic-sheet.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ProviderDiagnosticSheet({
|
||||
provider,
|
||||
visible,
|
||||
onClose,
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
setLoading(true);
|
||||
setDiagnostic(null);
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title={providerLabel}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={["50%", "85%"]}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={sheetStyles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
||||
</View>
|
||||
) : diagnostic ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={sheetStyles.scrollContainer}
|
||||
contentContainerStyle={sheetStyles.scrollContent}
|
||||
>
|
||||
<Text style={sheetStyles.diagnosticText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetStyles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
diagnosticText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
lineHeight: theme.fontSize.sm * 1.6,
|
||||
},
|
||||
}));
|
||||
@@ -3,13 +3,14 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
"claude-acp": ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
if (!allAnswered || isResponding) return;
|
||||
setRespondingAction("submit");
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions!.length; i++) {
|
||||
@@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={otherText}
|
||||
onChangeText={(text) => setOtherText(qIndex, text)}
|
||||
onSubmitEditing={handleSubmit}
|
||||
editable={!isResponding}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -53,13 +54,7 @@ import {
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -105,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
};
|
||||
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return theme.colors.palette.purple[500];
|
||||
case "open":
|
||||
return theme.colors.palette.green[500];
|
||||
case "closed":
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
projects: SidebarProjectEntry[];
|
||||
@@ -145,6 +145,8 @@ interface ProjectHeaderRowProps {
|
||||
isDragging: boolean;
|
||||
isArchiving?: boolean;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
@@ -172,7 +174,8 @@ interface WorkspaceRowInnerProps {
|
||||
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = getWorkspacePrIconColor(theme, hint.state);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -200,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
pressed && styles.workspacePrBadgePressed,
|
||||
]}
|
||||
>
|
||||
<GitPullRequest size={12} color={activeColor} />
|
||||
<GitPullRequest size={12} color={iconColor} />
|
||||
<Text
|
||||
style={[
|
||||
styles.workspacePrBadgeText,
|
||||
{ color: activeColor },
|
||||
{ color: textColor },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
|
||||
#{hint.number}
|
||||
</Text>
|
||||
{isHovered && <ExternalLink size={10} color={activeColor} />}
|
||||
{isHovered && <ExternalLink size={10} color={textColor} />}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -704,8 +707,11 @@ function ProjectHeaderRow({
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
onRemoveProject,
|
||||
removeProjectStatus = "idle",
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint = isCompactFormFactor();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
@@ -792,16 +798,55 @@ function ProjectHeaderRow({
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
<View
|
||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.projectKebabButton,
|
||||
hovered && styles.projectKebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${project.projectKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${project.projectKey}`}
|
||||
leading={<Trash2 size={14} color={theme.colors.foregroundMuted} />}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
@@ -1068,7 +1113,7 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
@@ -1209,160 +1254,6 @@ function WorkspaceRowWithMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenuContent({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
redirectAfterArchive();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron={null}
|
||||
onPress={onPress}
|
||||
serverId={null}
|
||||
canCreateWorktree={false}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
menuController={contextMenu}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
||||
pendingLabel="Hiding..."
|
||||
destructive
|
||||
onSelect={handleArchiveWorkspace}
|
||||
>
|
||||
Hide from sidebar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenu(props: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<NonGitProjectRowWithMenuContent {...props} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FlattenedProjectRow({
|
||||
project,
|
||||
displayName,
|
||||
@@ -1378,6 +1269,8 @@ function FlattenedProjectRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
isProjectActive = false,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1393,25 +1286,9 @@ function FlattenedProjectRow({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
@@ -1431,6 +1308,8 @@ function FlattenedProjectRow({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
menuController={null}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
@@ -1601,6 +1480,48 @@ function ProjectBlock({
|
||||
[onWorkspaceReorder, project.projectKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
|
||||
const handleRemoveProject = useCallback(() => {
|
||||
if (isRemovingProject || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Remove project?",
|
||||
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
try {
|
||||
for (const ws of project.workspaces) {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to remove project");
|
||||
} finally {
|
||||
setIsRemovingProject(false);
|
||||
}
|
||||
})();
|
||||
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{rowModel.kind === "workspace_link" ? (
|
||||
@@ -1625,6 +1546,8 @@ function ProjectBlock({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={isProjectActive}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1643,7 +1566,10 @@ function ProjectBlock({
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
@@ -2144,6 +2070,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectKebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -84,6 +84,7 @@ interface SplitContainerProps {
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
@@ -176,18 +177,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
paneId,
|
||||
buildPaneContentModel,
|
||||
}: MountedTabSlotProps) {
|
||||
useEffect(() => {
|
||||
if (tabDescriptor.target.kind !== "terminal") {
|
||||
return;
|
||||
}
|
||||
console.log("[terminal-tab-slot]", {
|
||||
paneId,
|
||||
tabId: tabDescriptor.tabId,
|
||||
terminalId: tabDescriptor.target.terminalId,
|
||||
isVisible,
|
||||
isPaneFocused,
|
||||
});
|
||||
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
|
||||
|
||||
const content = useMemo(
|
||||
() =>
|
||||
@@ -265,6 +254,7 @@ export function SplitContainer({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -534,6 +524,7 @@ export function SplitContainer({
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -656,6 +647,7 @@ function SplitNodeView({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -691,6 +683,7 @@ function SplitNodeView({
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -741,6 +734,7 @@ function SplitNodeView({
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -790,6 +784,7 @@ function SplitPaneView({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -901,6 +896,7 @@ function SplitPaneView({
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
|
||||
@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
|
||||
@@ -73,6 +73,12 @@ export interface ComboboxProps {
|
||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||
*/
|
||||
desktopPreventInitialFlash?: boolean;
|
||||
/** Minimum width for the desktop popover (overrides trigger-based width). */
|
||||
desktopMinWidth?: number;
|
||||
/** Fixed height for the desktop popover (overrides default 400px max). */
|
||||
desktopFixedHeight?: number;
|
||||
/** Content rendered above the scroll area on desktop (sticky header). */
|
||||
stickyHeader?: ReactNode;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -150,6 +156,8 @@ export interface ComboboxItemProps {
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
||||
elevated?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -163,6 +171,7 @@ export function ComboboxItem({
|
||||
selected,
|
||||
active,
|
||||
disabled,
|
||||
elevated,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
@@ -187,19 +196,19 @@ export function ComboboxItem({
|
||||
onPress={onPress}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.comboboxItem,
|
||||
hovered && styles.comboboxItemHovered,
|
||||
pressed && styles.comboboxItemPressed,
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -246,6 +255,9 @@ export function Combobox({
|
||||
stackBehavior,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPreventInitialFlash = true,
|
||||
desktopMinWidth,
|
||||
desktopFixedHeight,
|
||||
stickyHeader,
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
@@ -390,12 +402,27 @@ export function Combobox({
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
// For top-placed popups: once position resolves, use bottom-based CSS positioning
|
||||
// so height changes grow upward naturally without floating-ui needing to reposition.
|
||||
const useStableBottom =
|
||||
!isDesktopAboveSearch &&
|
||||
IS_WEB &&
|
||||
!isMobile &&
|
||||
hasResolvedDesktopPosition &&
|
||||
desktopPlacement.startsWith("top") &&
|
||||
referenceTop !== null;
|
||||
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: floatingStyles;
|
||||
: useStableBottom
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||
}
|
||||
: floatingStyles;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -662,6 +689,7 @@ export function Combobox({
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||
</View>
|
||||
{stickyHeader}
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.comboboxScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -687,13 +715,16 @@ export function Combobox({
|
||||
styles.desktopContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
minWidth: referenceWidth ?? 200,
|
||||
maxWidth: 400,
|
||||
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
||||
maxWidth: Math.max(400, desktopMinWidth ?? 0),
|
||||
},
|
||||
desktopFixedHeight != null
|
||||
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
||||
: null,
|
||||
desktopPositionStyle,
|
||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||
typeof availableSize?.height === "number"
|
||||
? { maxHeight: Math.min(availableSize.height, 400) }
|
||||
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
|
||||
: null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
@@ -701,14 +732,17 @@ export function Combobox({
|
||||
onLayout={() => update()}
|
||||
>
|
||||
{children ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
<>
|
||||
{stickyHeader}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopChildrenScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{effectiveOptionsPosition === "above-search" ? (
|
||||
@@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
comboboxItemHoveredElevated: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
comboboxItemPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
comboboxItemPressedElevated: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
comboboxItemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
@@ -807,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemContentInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
@@ -817,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -876,6 +921,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
desktopScrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
desktopChildrenScrollContent: {
|
||||
// No padding — custom children (e.g. model selector) control their own spacing
|
||||
},
|
||||
desktopScrollContentAboveSearch: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "flex-end",
|
||||
|
||||
@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -595,6 +596,7 @@ export function ContextMenuItem({
|
||||
successLabel,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
tooltip,
|
||||
}: PropsWithChildren<{
|
||||
description?: string;
|
||||
onSelect?: () => void;
|
||||
@@ -612,6 +614,7 @@ export function ContextMenuItem({
|
||||
successLabel?: string;
|
||||
closeOnSelect?: boolean;
|
||||
testID?: string;
|
||||
tooltip?: string;
|
||||
}>): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { setOpen } = useContextMenuContext("ContextMenuItem");
|
||||
@@ -642,7 +645,7 @@ export function ContextMenuItem({
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
) : null);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
@@ -704,6 +707,19 @@ export function ContextMenuItem({
|
||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={10}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
|
||||
successLabel,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
tooltip,
|
||||
}: PropsWithChildren<{
|
||||
description?: string;
|
||||
onSelect?: () => void;
|
||||
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
|
||||
successLabel?: string;
|
||||
closeOnSelect?: boolean;
|
||||
testID?: string;
|
||||
tooltip?: string;
|
||||
}>): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
||||
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
) : null);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
|
||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={10}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
65
packages/app/src/components/ui/status-badge.tsx
Normal file
65
packages/app/src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
type StatusBadgeVariant = "success" | "error" | "muted";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
variant?: StatusBadgeVariant;
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "error" && styles.pillError,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.pillText,
|
||||
variant === "success" && styles.pillTextSuccess,
|
||||
variant === "error" && styles.pillTextError,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
pill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
borderColor: theme.colors.palette.red[800],
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
pillTextSuccess: {
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
pillTextError: {
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
}));
|
||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
openOnPress: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -323,9 +333,17 @@ export function TooltipTrigger({
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.openOnPress) {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPress],
|
||||
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
@@ -492,7 +510,7 @@ export function TooltipContent({
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
@@ -513,7 +531,7 @@ export function TooltipContent({
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ export function useWebElementScrollbar(
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", update, { passive: true });
|
||||
element.addEventListener("input", update, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(element);
|
||||
@@ -90,7 +89,6 @@ export function useWebElementScrollbar(
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", update);
|
||||
element.removeEventListener("input", update);
|
||||
resizeObserver.disconnect();
|
||||
element.removeAttribute("data-hide-scrollbar");
|
||||
(element.style as any).scrollbarWidth = "";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
setupHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing[6],
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
},
|
||||
setupLink: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
setupLinkText: {
|
||||
color: theme.colors.accent,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -250,12 +270,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
@@ -326,6 +346,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
{!showHostList && Platform.OS !== "web" && (
|
||||
<>
|
||||
<Text style={styles.setupHint}>
|
||||
You need the Paseo desktop app or server running on your computer first.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.setupLink}
|
||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||
>
|
||||
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
@@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const gestureAnimatingRef = useRef(false);
|
||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
return;
|
||||
}
|
||||
|
||||
if (gestureAnimatingRef.current) {
|
||||
gestureAnimatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
processTimelineResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -67,6 +69,22 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
): boolean {
|
||||
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
|
||||
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
@@ -309,6 +327,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -324,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -349,6 +374,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
|
||||
if (hasUsageUpdate) {
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, {
|
||||
...current,
|
||||
lastUsage: agent.lastUsage,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -603,6 +637,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
updateSessionClient(serverId, client);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo?.features?.providersSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchProvidersSnapshot(serverId, client);
|
||||
}, [client, isConnected, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
@@ -1105,6 +1167,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const gestureAnimatingRef = useRef(false);
|
||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
return;
|
||||
}
|
||||
|
||||
// Gesture onEnd already started the animation on the UI thread — skip to avoid
|
||||
// a second competing withTiming that can desync translateX and backdropOpacity
|
||||
// after a provider remount (e.g. theme change).
|
||||
if (gestureAnimatingRef.current) {
|
||||
gestureAnimatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}),
|
||||
[
|
||||
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -10,30 +9,26 @@ import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCw,
|
||||
Terminal,
|
||||
Copy,
|
||||
FileText,
|
||||
Smartphone,
|
||||
Activity,
|
||||
} from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliSymlinkInstructions,
|
||||
getCliDaemonStatus,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonPairing,
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
type CliSymlinkInstructions,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopPairingOffer,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
@@ -50,28 +45,22 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
|
||||
const loadDaemonData = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
|
||||
.then(([status, logs, version]) => {
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setDaemonStatus(status);
|
||||
setDaemonLogs(logs);
|
||||
setDaemonVersion(version.version);
|
||||
setDaemonVersion(status.version);
|
||||
setStatusError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -218,40 +207,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
updateSettings,
|
||||
]);
|
||||
|
||||
const handleOpenCliSymlinkInstructions = useCallback(() => {
|
||||
if (!showSection || isLoadingCliSymlinkInstructions) {
|
||||
return;
|
||||
}
|
||||
setIsLoadingCliSymlinkInstructions(true);
|
||||
setCliStatusMessage(null);
|
||||
void getCliSymlinkInstructions()
|
||||
.then((instructions) => {
|
||||
setCliSymlinkInstructions(instructions);
|
||||
setIsCliSymlinkModalOpen(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCliSymlinkInstructions(false);
|
||||
});
|
||||
}, [isLoadingCliSymlinkInstructions, showSection]);
|
||||
|
||||
const handleCopyCliSymlinkCommands = useCallback(() => {
|
||||
if (!cliSymlinkInstructions?.commands) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "CLI symlink commands copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy CLI symlink commands", error);
|
||||
Alert.alert("Error", "Unable to copy CLI symlink commands.");
|
||||
});
|
||||
}, [cliSymlinkInstructions?.commands]);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = daemonLogs?.logPath;
|
||||
if (!logPath) {
|
||||
@@ -275,45 +230,32 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
setIsLogsModalOpen(true);
|
||||
}, [daemonLogs]);
|
||||
|
||||
const handleOpenPairingModal = useCallback(() => {
|
||||
if (isLoadingPairing) {
|
||||
const handleOpenCliStatus = useCallback(async () => {
|
||||
setIsLoadingCliStatus(true);
|
||||
try {
|
||||
setCliStatusOutput(await getCliDaemonStatus());
|
||||
setIsCliStatusModalOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
||||
setIsCliStatusModalOpen(true);
|
||||
} finally {
|
||||
setIsLoadingCliStatus(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyCliStatus = useCallback(() => {
|
||||
if (!cliStatusOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPairingModalOpen(true);
|
||||
setIsLoadingPairing(true);
|
||||
setPairingStatusMessage(null);
|
||||
|
||||
void getDesktopDaemonPairing()
|
||||
.then((pairing) => {
|
||||
setPairingOffer(pairing);
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
setPairingStatusMessage("Relay pairing is not available.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPairingOffer(null);
|
||||
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingPairing(false);
|
||||
});
|
||||
}, [isLoadingPairing]);
|
||||
|
||||
const handleCopyPairingLink = useCallback(() => {
|
||||
if (!pairingOffer?.url) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(pairingOffer.url)
|
||||
void Clipboard.setStringAsync(cliStatusOutput)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Pairing link copied.");
|
||||
Alert.alert("Copied", "Status copied to clipboard.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy pairing link", error);
|
||||
Alert.alert("Error", "Unable to copy pairing link.");
|
||||
console.error("[Settings] Failed to copy daemon status", error);
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
}, [cliStatusOutput]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
@@ -321,14 +263,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
@@ -336,10 +278,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -348,10 +290,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
@@ -379,10 +321,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
: "Pause"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
@@ -401,26 +343,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenCliSymlinkInstructions}
|
||||
disabled={isLoadingCliSymlinkInstructions}
|
||||
>
|
||||
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
@@ -444,18 +370,21 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenPairingModal}
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
Pair device
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -469,47 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliSymlinkModalOpen}
|
||||
onClose={() => setIsCliSymlinkModalOpen(false)}
|
||||
title="Add paseo to your shell"
|
||||
testID="managed-daemon-cli-symlink-dialog"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Paseo does not add the command for you. Run the command below in your terminal.
|
||||
</Text>
|
||||
{cliSymlinkInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeBlock} selectable>
|
||||
{cliSymlinkInstructions?.commands ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
|
||||
Copy commands
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
title="Pair device"
|
||||
testID="managed-daemon-pairing-dialog"
|
||||
>
|
||||
<PairingOfferDialogContent
|
||||
isLoading={isLoadingPairing}
|
||||
pairingOffer={pairingOffer}
|
||||
statusMessage={pairingStatusMessage}
|
||||
onCopyLink={handleCopyPairingLink}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
@@ -518,151 +406,41 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliStatusModalOpen}
|
||||
onClose={() => setIsCliStatusModalOpen(false)}
|
||||
title="Daemon status"
|
||||
testID="daemon-cli-status-dialog"
|
||||
snapPoints={["60%", "85%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{cliStatusOutput ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliStatus}>
|
||||
Copy
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
pairingOffer: DesktopPairingOffer | null;
|
||||
statusMessage: string | null;
|
||||
onCopyLink: () => void;
|
||||
}) {
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setQrError(null);
|
||||
setQrDataUrl(null);
|
||||
|
||||
void QRCode.toDataURL(pairingOffer.url, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrDataUrl(dataUrl);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.linkSection}>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
@@ -673,10 +451,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
valueText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -686,11 +460,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
hintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -713,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
pairingState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
qrCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
aspectRatio: 1,
|
||||
alignSelf: "stretch",
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
linkLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
linkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 18,
|
||||
},
|
||||
logOutput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
189
packages/app/src/desktop/components/integrations-section.tsx
Normal file
189
packages/app/src/desktop/components/integrations-section.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
getCliInstallStatus,
|
||||
installCli,
|
||||
getSkillsInstallStatus,
|
||||
installSkills,
|
||||
type InstallStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
|
||||
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
|
||||
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
if (!showSection) return;
|
||||
void getCliInstallStatus()
|
||||
.then(setCliStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to load CLI status", error);
|
||||
});
|
||||
void getSkillsInstallStatus()
|
||||
.then(setSkillsStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to load skills status", error);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
loadStatus();
|
||||
return undefined;
|
||||
}, [loadStatus, showSection]),
|
||||
);
|
||||
|
||||
const handleInstallCli = useCallback(() => {
|
||||
if (isInstallingCli) return;
|
||||
setIsInstallingCli(true);
|
||||
void installCli()
|
||||
.then(setCliStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to install CLI", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingCli(false);
|
||||
});
|
||||
}, [isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isInstallingSkills) return;
|
||||
setIsInstallingSkills(true);
|
||||
void installSkills()
|
||||
.then(setSkillsStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to install skills", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingSkills(false);
|
||||
});
|
||||
}, [isInstallingSkills]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
|
||||
<View style={styles.headerLinks}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
>
|
||||
CLI docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
>
|
||||
Skills docs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Control and script agents from your terminal.
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI.
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[0],
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
installedLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal file
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useCallback } from "react";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw, Copy, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const pairingQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing"],
|
||||
queryFn: getDesktopDaemonPairing,
|
||||
enabled: showSection,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const qrQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
|
||||
queryFn: () =>
|
||||
QRCode.toDataURL(pairingQuery.data!.url!, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: !!pairingQuery.data?.url,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!pairingQuery.data?.url) return;
|
||||
await Clipboard.setStringAsync(pairingQuery.data.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [pairingQuery.data?.url]);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
{pairingQuery.isPending ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
) : pairingQuery.isError ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>
|
||||
{pairingQuery.error instanceof Error
|
||||
? pairingQuery.error.message
|
||||
: "Failed to load pairing offer."}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
) : !pairingQuery.data?.url ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>
|
||||
{pairingQuery.data?.relayEnabled === false
|
||||
? "Relay is not enabled. Enable relay to pair a device."
|
||||
: "Pairing offer unavailable."}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>
|
||||
Scan this QR code with Paseo on your phone, or copy the link below.
|
||||
</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
{qrQuery.data ? (
|
||||
<Image source={{ uri: qrQuery.data }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrQuery.isError ? (
|
||||
<Text style={styles.hint}>QR code unavailable.</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.linkInput}
|
||||
value={pairingQuery.data.url}
|
||||
readOnly
|
||||
selectTextOnFocus
|
||||
selectionColor={theme.colors.accent}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
copied ? (
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
|
||||
) : (
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={() => void handleCopyLink()}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textAlign: "center",
|
||||
},
|
||||
qrContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
width: 320,
|
||||
height: 320,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
inputWrapper: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
linkInput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
outlineStyle: "none",
|
||||
} as any,
|
||||
}));
|
||||
@@ -10,6 +10,8 @@ export type DesktopDaemonStatus = {
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
home: string;
|
||||
version: string | null;
|
||||
desktopManaged: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
export type CliSymlinkInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
@@ -86,6 +82,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
pid: toNumberOrNull(raw.pid),
|
||||
home: toStringOrNull(raw.home) ?? "",
|
||||
version: toStringOrNull(raw.version),
|
||||
desktopManaged: raw.desktopManaged === true,
|
||||
error: toStringOrNull(raw.error),
|
||||
};
|
||||
}
|
||||
@@ -111,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: toStringOrNull(raw.title) ?? "",
|
||||
detail: toStringOrNull(raw.detail) ?? "",
|
||||
commands: toStringOrNull(raw.commands) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isElectronRuntime();
|
||||
}
|
||||
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||
}
|
||||
|
||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||
const instructions = parseCliSymlinkInstructionsInternal(raw);
|
||||
if (!instructions) {
|
||||
throw new Error("Unexpected CLI symlink instructions response.");
|
||||
export async function getCliDaemonStatus(): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
throw new Error("Unexpected CLI daemon status response.");
|
||||
}
|
||||
return instructions;
|
||||
}
|
||||
|
||||
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
|
||||
return raw;
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void;
|
||||
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
function parseInstallStatus(raw: unknown): InstallStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected install status response.");
|
||||
}
|
||||
return { installed: raw.installed === true };
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
|
||||
export interface DesktopHostBridge {
|
||||
platform?: string;
|
||||
invoke?: DesktopInvokeBridge["invoke"];
|
||||
getPendingOpenProject?: () => Promise<string | null>;
|
||||
events?: DesktopEventsBridge;
|
||||
window?: DesktopWindowModuleBridge;
|
||||
dialog?: DesktopDialogBridge;
|
||||
|
||||
@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
const featureValues = draftConfig.featureValues;
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
|
||||
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
|
||||
draftConfig?.modeId ?? null,
|
||||
draftConfig?.model ?? null,
|
||||
draftConfig?.thinkingOptionId ?? null,
|
||||
draftConfig?.featureValues ?? null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
@@ -8,9 +7,10 @@ import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
mergeProviderPreferences,
|
||||
@@ -84,6 +84,7 @@ type UseAgentFormStateResult = {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
allProviderEntries?: ProviderSnapshotEntry[];
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
@@ -368,116 +369,61 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Session state for provider model listing
|
||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
|
||||
const providerDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) =>
|
||||
available.has(definition.id as AgentProvider),
|
||||
);
|
||||
}, [availableProvidersQuery.data]);
|
||||
|
||||
const providerDefinitionMap = useMemo(
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
|
||||
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
[providerDefinitions],
|
||||
[snapshotProviderDefinitions],
|
||||
);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
const next = trimmed.length > 0 ? trimmed : undefined;
|
||||
const timer = setTimeout(() => setDebouncedCwd(next), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const providerModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
providerDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const availableModels = providerModelsQuery.data ?? null;
|
||||
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: providerDefinitions.map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(def.id as AgentProvider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const allProviderModels = useMemo(() => {
|
||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||
const readyProviders = new Set(
|
||||
(snapshotEntries ?? [])
|
||||
.filter((entry) => entry.status === "ready")
|
||||
.map((entry) => entry.provider),
|
||||
);
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (let i = 0; i < providerDefinitions.length; i++) {
|
||||
const query = allProviderModelQueries[i];
|
||||
if (query?.data) {
|
||||
map.set(providerDefinitions[i]!.id, query.data);
|
||||
}
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
map.set(entry.provider, entry.models ?? []);
|
||||
}
|
||||
return map;
|
||||
}, [allProviderModelQueries, providerDefinitions]);
|
||||
|
||||
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
|
||||
}, [snapshotEntries]);
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
|
||||
[formState.provider, snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||
const snapshotSelectedProviderModes =
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
[];
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
@@ -502,7 +448,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds,
|
||||
providerDefinitionMap,
|
||||
selectableProviderDefinitionMap,
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
@@ -527,7 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
providerDefinitionMap,
|
||||
selectableProviderDefinitionMap,
|
||||
]);
|
||||
|
||||
// Auto-select the first online host when:
|
||||
@@ -567,8 +513,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
const isValidModel = (m: string) =>
|
||||
@@ -606,12 +555,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
},
|
||||
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
||||
[
|
||||
allProviderModels,
|
||||
preferences?.providerPreferences,
|
||||
selectableProviderDefinitionMap,
|
||||
updatePreferences,
|
||||
],
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
@@ -631,7 +588,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
void updatePreferences({ provider });
|
||||
},
|
||||
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
@@ -688,8 +645,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
void providerModelsQuery.refetch();
|
||||
}, [providerModelsQuery]);
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
@@ -722,13 +679,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||
const modelError =
|
||||
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
|
||||
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
@@ -751,6 +706,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
allProviderEntries,
|
||||
modeOptions,
|
||||
availableModels: availableModels ?? [],
|
||||
allProviderModels,
|
||||
@@ -781,6 +737,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
allProviderEntries,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
|
||||
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
|
||||
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
|
||||
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
|
||||
|
||||
function createAgent(id: string): Agent {
|
||||
const now = new Date("2026-02-19T00:00:00.000Z");
|
||||
@@ -74,7 +73,6 @@ function createBaseMemory(
|
||||
return {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
|
||||
function expectSyncErrorSync(state: ReadyState): void {
|
||||
expect(state.sync.status).toBe("sync_error");
|
||||
if (state.sync.status !== "sync_error") {
|
||||
throw new Error("expected sync_error sync state");
|
||||
}
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
describe("deriveAgentScreenViewState", () => {
|
||||
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("overlay");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("uses toast catching-up state for already-hydrated agents", () => {
|
||||
it("uses silent catching-up state for already-hydrated agents", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
expect(sync.ui).toBe("silent");
|
||||
});
|
||||
|
||||
it("keeps sync errors non-blocking once the screen was ready", () => {
|
||||
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
expectSyncErrorSync(ready);
|
||||
});
|
||||
|
||||
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
|
||||
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
memory: initialMemory,
|
||||
});
|
||||
const errorReady = expectReadyState(errorResult.state);
|
||||
const errorSync = expectSyncErrorSync(errorReady);
|
||||
|
||||
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
expectSyncErrorSync(errorReady);
|
||||
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
|
||||
const retryInput: AgentScreenMachineInput = {
|
||||
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
|
||||
const retrySync = expectCatchingUpSync(retryReady);
|
||||
|
||||
expect(retrySync.ui).toBe("silent");
|
||||
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
});
|
||||
|
||||
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
expectSyncErrorSync(ready);
|
||||
|
||||
expect(ready.source).toBe("stale");
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
});
|
||||
|
||||
it("emits sync error toast only on transition into sync_error", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectSyncErrorSync(firstReady);
|
||||
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectSyncErrorSync(secondReady);
|
||||
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
|
||||
});
|
||||
|
||||
it("returns blocking error before first paint when refresh fails", () => {
|
||||
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.agent.status).toBe("closed");
|
||||
});
|
||||
|
||||
it("emits history refresh toast only on transition into toast catch-up state", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectCatchingUpSync(firstReady);
|
||||
|
||||
expect(firstSync.ui).toBe("toast");
|
||||
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectCatchingUpSync(secondReady);
|
||||
|
||||
expect(secondSync.ui).toBe("toast");
|
||||
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
|
||||
const baseInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
const initialMemory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
|
||||
const firstCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: initialMemory,
|
||||
});
|
||||
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
|
||||
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
|
||||
expect(firstCatchingUpSync.ui).toBe("toast");
|
||||
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const idle = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: false },
|
||||
memory: firstCatchingUp.memory,
|
||||
});
|
||||
const idleReady = expectReadyState(idle.state);
|
||||
expect(idleReady.sync.status).toBe("idle");
|
||||
|
||||
const secondCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: idle.memory,
|
||||
});
|
||||
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
|
||||
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
|
||||
expect(secondCatchingUpSync.ui).toBe("toast");
|
||||
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
});
|
||||
|
||||
it("clears initial sync failure memory after history is hydrated", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(sync.ui).toBe("silent");
|
||||
expect(result.memory.hadInitialSyncFailure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,12 +39,9 @@ function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInpu
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
hasRenderedReady: boolean;
|
||||
lastReadyAgent: AgentScreenAgent | null;
|
||||
activeToastLatch: AgentScreenToastLatch;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}
|
||||
|
||||
@@ -54,17 +51,8 @@ export type AgentScreenReadySyncState =
|
||||
| {
|
||||
status: "catching_up";
|
||||
ui: "overlay" | "silent";
|
||||
shouldEmitHistoryRefreshToast: false;
|
||||
}
|
||||
| {
|
||||
status: "catching_up";
|
||||
ui: "toast";
|
||||
shouldEmitHistoryRefreshToast: boolean;
|
||||
}
|
||||
| {
|
||||
status: "sync_error";
|
||||
shouldEmitSyncErrorToast: boolean;
|
||||
};
|
||||
| { status: "sync_error" };
|
||||
|
||||
export type AgentScreenViewState =
|
||||
| {
|
||||
@@ -98,7 +86,6 @@ export function deriveAgentScreenViewState({
|
||||
const nextMemory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: memory.hasRenderedReady,
|
||||
lastReadyAgent: memory.lastReadyAgent,
|
||||
activeToastLatch: memory.activeToastLatch,
|
||||
hadInitialSyncFailure: memory.hadInitialSyncFailure,
|
||||
};
|
||||
|
||||
@@ -180,45 +167,23 @@ export function deriveAgentScreenViewState({
|
||||
|
||||
let sync: AgentScreenReadySyncState;
|
||||
if (!input.isConnected) {
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = { status: "reconnecting" };
|
||||
} else if (input.missingAgentState.kind === "error") {
|
||||
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
|
||||
nextMemory.activeToastLatch = "sync_error";
|
||||
sync = {
|
||||
status: "sync_error",
|
||||
shouldEmitSyncErrorToast,
|
||||
};
|
||||
sync = { status: "sync_error" };
|
||||
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
||||
let ui: "overlay" | "toast" | "silent";
|
||||
let ui: "overlay" | "silent";
|
||||
if (input.shouldUseOptimisticStream) {
|
||||
ui = "silent";
|
||||
} else if (input.hasHydratedHistoryBefore) {
|
||||
ui = "toast";
|
||||
ui = "silent";
|
||||
} else if (nextMemory.hadInitialSyncFailure) {
|
||||
ui = "silent";
|
||||
} else {
|
||||
ui = "overlay";
|
||||
}
|
||||
|
||||
if (ui === "toast") {
|
||||
const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh";
|
||||
nextMemory.activeToastLatch = "history_refresh";
|
||||
sync = {
|
||||
status: "catching_up",
|
||||
ui,
|
||||
shouldEmitHistoryRefreshToast,
|
||||
};
|
||||
} else {
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = {
|
||||
status: "catching_up",
|
||||
ui,
|
||||
shouldEmitHistoryRefreshToast: false,
|
||||
};
|
||||
}
|
||||
sync = { status: "catching_up", ui };
|
||||
} else {
|
||||
nextMemory.activeToastLatch = "none";
|
||||
sync = { status: "idle" };
|
||||
}
|
||||
|
||||
@@ -245,7 +210,6 @@ export function useAgentScreenStateMachine({
|
||||
const memoryRef = useRef<AgentScreenMachineMemory>({
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
});
|
||||
|
||||
@@ -254,7 +218,6 @@ export function useAgentScreenStateMachine({
|
||||
memoryRef.current = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
};
|
||||
}
|
||||
|
||||
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("use-changes-preferences", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("defaults to unified layout with visible whitespace", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "diff-wrap-lines") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
hideWhitespace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads persisted layout and whitespace preferences", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:changes-preferences") {
|
||||
return JSON.stringify({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
|
||||
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
|
||||
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
|
||||
|
||||
const changesPreferencesSchema = z.object({
|
||||
layout: z.enum(["unified", "split"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
hideWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface ChangesPreferences {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
};
|
||||
|
||||
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
|
||||
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
return true;
|
||||
}
|
||||
if (legacyValue === "false") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
|
||||
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) {
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
|
||||
}
|
||||
}
|
||||
|
||||
const legacyWrapLines = await loadLegacyWrapLinesPreference();
|
||||
const next = {
|
||||
...DEFAULT_CHANGES_PREFERENCES,
|
||||
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
|
||||
} satisfies ChangesPreferences;
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface UseChangesPreferencesReturn {
|
||||
preferences: ChangesPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChangesPreferences(): UseChangesPreferencesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
|
||||
queryFn: loadChangesPreferencesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<ChangesPreferences>) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_CHANGES_PREFERENCES;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
|
||||
isLoading: isPending,
|
||||
updatePreferences,
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef],
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef],
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -165,7 +165,7 @@ export function useCommandCenter() {
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
||||
}, [activeServerId]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
@@ -220,7 +220,7 @@ export function useCommandCenter() {
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import type { ToastShowOptions } from "@/components/toast-host";
|
||||
|
||||
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
|
||||
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||
|
||||
interface UseDelayedHistoryRefreshToastParams {
|
||||
isCatchingUp: boolean;
|
||||
indicatorColor: string;
|
||||
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
}
|
||||
|
||||
export function useDelayedHistoryRefreshToast({
|
||||
isCatchingUp,
|
||||
indicatorColor,
|
||||
showToast,
|
||||
}: UseDelayedHistoryRefreshToastParams): void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wasCatchingUpRef = useRef(false);
|
||||
const isCatchingUpRef = useRef(false);
|
||||
const showToastRef = useRef(showToast);
|
||||
const indicatorColorRef = useRef(indicatorColor);
|
||||
|
||||
useEffect(() => {
|
||||
showToastRef.current = showToast;
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
indicatorColorRef.current = indicatorColor;
|
||||
}, [indicatorColor]);
|
||||
|
||||
useEffect(() => {
|
||||
isCatchingUpRef.current = isCatchingUp;
|
||||
|
||||
const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp;
|
||||
const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp;
|
||||
|
||||
if (enteredCatchUp) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
if (!isCatchingUpRef.current) {
|
||||
return;
|
||||
}
|
||||
showToastRef.current("Refreshing", {
|
||||
icon: <ActivityIndicator size="small" color={indicatorColorRef.current} />,
|
||||
durationMs: HISTORY_REFRESH_TOAST_DURATION_MS,
|
||||
testID: "agent-history-refresh-toast",
|
||||
});
|
||||
}, HISTORY_REFRESH_TOAST_DELAY_MS);
|
||||
} else if (exitedCatchUp && timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
wasCatchingUpRef.current = isCatchingUp;
|
||||
}, [isCatchingUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal file
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
function pruneFeatureValues(
|
||||
featureValues: Record<string, unknown>,
|
||||
features: AgentFeature[],
|
||||
): Record<string, unknown> {
|
||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
||||
if (!allowedFeatureIds.has(featureId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[featureId] = value;
|
||||
}
|
||||
|
||||
return changed ? next : featureValues;
|
||||
}
|
||||
|
||||
function applyFeatureValues(
|
||||
features: AgentFeature[],
|
||||
featureValues: Record<string, unknown>,
|
||||
): AgentFeature[] {
|
||||
if (Object.keys(featureValues).length === 0) {
|
||||
return features;
|
||||
}
|
||||
|
||||
return features.map((feature) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
return {
|
||||
...feature,
|
||||
value: featureValues[feature.id],
|
||||
} as AgentFeature;
|
||||
});
|
||||
}
|
||||
|
||||
type DraftFeatureConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
|
||||
export function useDraftAgentFeatures(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
thinkingOptionId: string | null | undefined;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
if (!normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
cwd: normalizedCwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
||||
|
||||
const featuresQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerFeatures",
|
||||
serverId ?? null,
|
||||
provider,
|
||||
normalizedCwd || null,
|
||||
modeId ?? null,
|
||||
modelId ?? null,
|
||||
thinkingOptionId ?? null,
|
||||
],
|
||||
enabled: Boolean(serverId && client && isConnected && draftConfig),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !draftConfig) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderFeatures(draftConfig);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const features = useMemo(() => {
|
||||
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
|
||||
}, [featureValues, featuresQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = pruneFeatureValues(featureValues, features);
|
||||
if (next !== featureValues) {
|
||||
setFeatureValues(next);
|
||||
}
|
||||
}, [featureValues, features]);
|
||||
|
||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
|
||||
setFeatureValues((current) => {
|
||||
if (Object.is(current[featureId], value)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, [featureId]: value };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features,
|
||||
featureValues: effectiveFeatureValues,
|
||||
isLoading: featuresQuery.isLoading,
|
||||
setFeatureValue,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
|
||||
}
|
||||
|
||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useExplorerSidebarAnimation();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
onOpen();
|
||||
}, [onOpen, gestureAnimatingRef]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(enabled)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(onOpen)();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
onOpen,
|
||||
openGestureRef,
|
||||
handleGestureOpen,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
|
||||
@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
|
||||
@@ -129,6 +129,11 @@ export function useKeyboardShortcuts({
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "send":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.send",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-toggle",
|
||||
@@ -139,6 +144,11 @@ export function useKeyboardShortcuts({
|
||||
id: "message-input.dictation-cancel",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-confirm":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-confirm",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "voice-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.voice-toggle",
|
||||
|
||||
26
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
26
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
|
||||
describe("resolvePreferredEditorId", () => {
|
||||
it("keeps the stored editor when it is still available", () => {
|
||||
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
|
||||
});
|
||||
|
||||
it("falls back to the first available editor when the stored one is missing", () => {
|
||||
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
|
||||
});
|
||||
|
||||
it("falls back when a platform-specific file manager target is unavailable", () => {
|
||||
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
|
||||
});
|
||||
|
||||
it("keeps unknown editor ids when they are still available", () => {
|
||||
expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe(
|
||||
"unknown-editor",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no editors are available", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
availableEditorIds: readonly EditorTargetId[],
|
||||
storedEditorId: EditorTargetId | null | undefined,
|
||||
): EditorTargetId | null {
|
||||
if (
|
||||
storedEditorId &&
|
||||
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
|
||||
) {
|
||||
return storedEditorId;
|
||||
}
|
||||
return availableEditorIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function usePreferredEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: PREFERRED_EDITOR_QUERY_KEY,
|
||||
queryFn: loadPreferredEditor,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferredEditor = useCallback(
|
||||
async (editorId: EditorTargetId | null) => {
|
||||
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
|
||||
if (editorId) {
|
||||
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferredEditorId: data ?? null,
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
}
|
||||
86
packages/app/src/hooks/use-providers-snapshot.ts
Normal file
86
packages/app/src/hooks/use-providers-snapshot.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null) {
|
||||
return ["providersSnapshot", serverId] as const;
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsSnapshot = useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot();
|
||||
}, [client]);
|
||||
|
||||
return {
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
isLoading: snapshotQuery.isLoading,
|
||||
isFetching: snapshotQuery.isFetching,
|
||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||
supportsSnapshot,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||
const queryKey = providersSnapshotQueryKey(serverId);
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot(),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
*/
|
||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
router.navigate(href as any);
|
||||
router.navigate(href);
|
||||
}
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind =
|
||||
| "queue"
|
||||
| "dictation-toggle"
|
||||
| "dictation-cancel"
|
||||
| "dictation-confirm"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
|
||||
|
||||
80
packages/app/src/keyboard/focus-scope.test.ts
Normal file
80
packages/app/src/keyboard/focus-scope.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveKeyboardFocusScope } from "./focus-scope";
|
||||
|
||||
class FakeNode {
|
||||
parentElement: FakeElement | null = null;
|
||||
}
|
||||
|
||||
class FakeElement extends FakeNode {
|
||||
tagName: string;
|
||||
isContentEditable = false;
|
||||
private selectors: Set<string>;
|
||||
|
||||
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
|
||||
super();
|
||||
this.tagName = (input?.tagName ?? "div").toUpperCase();
|
||||
this.selectors = new Set(input?.selectors ?? []);
|
||||
if (input?.isContentEditable) {
|
||||
this.isContentEditable = true;
|
||||
}
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (this.selectors.has(selector)) {
|
||||
return this;
|
||||
}
|
||||
return this.parentElement?.closest(selector) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveKeyboardFocusScope", () => {
|
||||
const globalRef = globalThis as {
|
||||
Element?: unknown;
|
||||
Node?: unknown;
|
||||
document?: { activeElement?: unknown };
|
||||
};
|
||||
const originalElement = globalRef.Element;
|
||||
const originalNode = globalRef.Node;
|
||||
const originalDocument = globalRef.document;
|
||||
|
||||
beforeEach(() => {
|
||||
globalRef.Element = FakeElement;
|
||||
globalRef.Node = FakeNode;
|
||||
globalRef.document = { activeElement: null };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalRef.Element = originalElement;
|
||||
globalRef.Node = originalNode;
|
||||
globalRef.document = originalDocument;
|
||||
});
|
||||
|
||||
it("resolves terminal scope from the direct keyboard event target", () => {
|
||||
const target = new FakeElement({ selectors: [".xterm"] });
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: target as unknown as EventTarget,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("terminal");
|
||||
});
|
||||
|
||||
it("falls back to activeElement when target is not an Element", () => {
|
||||
const activeElement = new FakeElement({ selectors: [".xterm"] });
|
||||
globalRef.document = { activeElement };
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: null,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("terminal");
|
||||
});
|
||||
|
||||
it("detects editable scope from activeElement fallback", () => {
|
||||
const activeElement = new FakeElement({ tagName: "input" });
|
||||
globalRef.document = { activeElement };
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: null,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("editable");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,77 @@
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
|
||||
function isElement(value: unknown): value is Element {
|
||||
return typeof Element !== "undefined" && value instanceof Element;
|
||||
}
|
||||
|
||||
function getFocusCandidateElements(target: EventTarget | null): Element[] {
|
||||
const candidates: Element[] = [];
|
||||
const pushUnique = (element: Element | null) => {
|
||||
if (!element || candidates.includes(element)) {
|
||||
return;
|
||||
}
|
||||
candidates.push(element);
|
||||
};
|
||||
|
||||
if (isElement(target)) {
|
||||
pushUnique(target);
|
||||
}
|
||||
|
||||
if (typeof Node !== "undefined" && target instanceof Node) {
|
||||
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && isElement(document.activeElement)) {
|
||||
pushUnique(document.activeElement);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveKeyboardFocusScope(input: {
|
||||
target: EventTarget | null;
|
||||
commandCenterOpen: boolean;
|
||||
}): KeyboardFocusScope {
|
||||
const { target, commandCenterOpen } = input;
|
||||
if (!(target instanceof Element)) {
|
||||
const candidates = getFocusCandidateElements(target);
|
||||
if (candidates.length === 0) {
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
|
||||
if (
|
||||
candidates.some((element) =>
|
||||
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
|
||||
)
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
if (
|
||||
commandCenterOpen &&
|
||||
(target.closest("[data-testid='command-center-panel']") ||
|
||||
target.closest("[data-testid='command-center-input']"))
|
||||
candidates.some((element) =>
|
||||
Boolean(
|
||||
element.closest("[data-testid='command-center-panel']") ||
|
||||
element.closest("[data-testid='command-center-input']"),
|
||||
),
|
||||
)
|
||||
) {
|
||||
return "command-center";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='message-input-root']")) {
|
||||
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
|
||||
return "message-input";
|
||||
}
|
||||
|
||||
const editable = target as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||
if (
|
||||
candidates.some((element) => {
|
||||
const editable = element as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
const tag = element.tagName.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
})
|
||||
) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "message-input.focus"
|
||||
| "message-input.send"
|
||||
| "message-input.dictation-toggle"
|
||||
| "message-input.dictation-cancel"
|
||||
| "message-input.dictation-confirm"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "workspace.tab.new"
|
||||
@@ -27,8 +29,10 @@ export type KeyboardActionId =
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.send"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
|
||||
@@ -860,6 +860,14 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "message-input-dictation-confirm-enter",
|
||||
action: "message-input.action",
|
||||
combo: "Enter",
|
||||
when: { commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "dictation-confirm" },
|
||||
},
|
||||
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
action: "message-input.action",
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -704,27 +703,6 @@ function AgentPanelBody({
|
||||
shouldUseOptimisticStream,
|
||||
]);
|
||||
|
||||
const isHistoryRefreshCatchingUp =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "toast";
|
||||
const shouldEmitSyncErrorToast =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "sync_error" &&
|
||||
viewState.sync.shouldEmitSyncErrorToast;
|
||||
|
||||
useDelayedHistoryRefreshToast({
|
||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
||||
indicatorColor: theme.colors.primary,
|
||||
showToast: panelToast.api.show,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitSyncErrorToast) {
|
||||
return;
|
||||
}
|
||||
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
||||
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
|
||||
@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type HostProfile,
|
||||
} from "@/types/host-connection";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
@@ -1681,6 +1683,41 @@ export class HostRuntimeStore {
|
||||
};
|
||||
}
|
||||
|
||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const isAnyOnline = (): boolean => {
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (snapshot?.connectionStatus === "online") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
if (isAnyOnline()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (isAnyOnline()) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ensureConnectedAll(): void {
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.ensureConnected();
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -219,6 +220,7 @@ function DraftAgentScreenContent({
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
allProviderEntries,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
@@ -757,6 +759,18 @@ function DraftAgentScreenContent({
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const {
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
setFeatureValue: setDraftFeatureValue,
|
||||
} = useDraftAgentFeatures({
|
||||
serverId: selectedServerId,
|
||||
provider: selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeId: selectedMode,
|
||||
modelId: effectiveDraftModelId,
|
||||
thinkingOptionId: effectiveDraftThinkingOptionId,
|
||||
});
|
||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||
const cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
@@ -773,8 +787,10 @@ function DraftAgentScreenContent({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
}, [
|
||||
draftFeatureValues,
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
@@ -878,6 +894,7 @@ function DraftAgentScreenContent({
|
||||
title: "New agent",
|
||||
cwd,
|
||||
model,
|
||||
features: draftFeatures,
|
||||
thinkingOptionId,
|
||||
labels: {},
|
||||
};
|
||||
@@ -896,6 +913,7 @@ function DraftAgentScreenContent({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
|
||||
const effectiveBaseBranch = baseBranch.trim();
|
||||
@@ -1248,6 +1266,8 @@ function DraftAgentScreenContent({
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
content: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
padding: theme.spacing[6],
|
||||
paddingBottom: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
marginBottom: theme.spacing[8],
|
||||
|
||||
@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={ChevronLeft}
|
||||
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
|
||||
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Stethoscope,
|
||||
Info,
|
||||
Shield,
|
||||
Puzzle,
|
||||
Blocks,
|
||||
Smartphone,
|
||||
} from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
@@ -50,7 +53,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -59,7 +64,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -69,10 +79,13 @@ type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "integrations"
|
||||
| "providers"
|
||||
| "diagnostics"
|
||||
| "about"
|
||||
| "permissions"
|
||||
| "daemon";
|
||||
| "daemon"
|
||||
| "pair-device";
|
||||
|
||||
interface SettingsSectionDef {
|
||||
id: SettingsSectionId;
|
||||
@@ -85,17 +98,23 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
];
|
||||
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
{ id: "providers", label: "Providers", icon: Blocks },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -177,13 +196,10 @@ interface HostsSectionProps {
|
||||
routeServerId: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
handleEditDaemon: (profile: HostProfile) => void;
|
||||
setAddConnectionTargetServerId: (id: string | null) => void;
|
||||
setPendingEditReopenServerId: (id: string | null) => void;
|
||||
setIsAddHostMethodVisible: (visible: boolean) => void;
|
||||
isAddHostMethodVisible: boolean;
|
||||
isDirectHostVisible: boolean;
|
||||
isPasteLinkVisible: boolean;
|
||||
addConnectionTargetServerId: string | null;
|
||||
closeAddConnectionFlow: () => void;
|
||||
goBackToAddConnectionMethods: () => void;
|
||||
setIsDirectHostVisible: (visible: boolean) => void;
|
||||
@@ -203,7 +219,6 @@ interface HostsSectionProps {
|
||||
handleSaveEditDaemon: (label: string) => Promise<void>;
|
||||
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
handleRemoveDaemon: (profile: HostProfile) => void;
|
||||
handleAddConnectionFromModal: () => void;
|
||||
restartConfirmationMessage: string;
|
||||
waitForCondition: (
|
||||
predicate: () => boolean,
|
||||
@@ -243,8 +258,6 @@ function HostsSection(props: HostsSectionProps) {
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={() => {
|
||||
props.setAddConnectionTargetServerId(null);
|
||||
props.setPendingEditReopenServerId(null);
|
||||
props.setIsAddHostMethodVisible(true);
|
||||
}}
|
||||
>
|
||||
@@ -264,22 +277,17 @@ function HostsSection(props: HostsSectionProps) {
|
||||
props.setIsPasteLinkVisible(true);
|
||||
}}
|
||||
onScanQr={() => {
|
||||
const targetServerId = props.addConnectionTargetServerId;
|
||||
const source = targetServerId ? "editHost" : "settings";
|
||||
const sourceServerId = props.routeServerId || targetServerId || undefined;
|
||||
const sourceServerId = props.routeServerId || undefined;
|
||||
props.closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: targetServerId
|
||||
? { source, targetServerId, sourceServerId }
|
||||
: { source, sourceServerId },
|
||||
params: { source: "settings", sourceServerId },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddHostModal
|
||||
visible={props.isDirectHostVisible}
|
||||
targetServerId={props.addConnectionTargetServerId ?? undefined}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
@@ -291,7 +299,6 @@ function HostsSection(props: HostsSectionProps) {
|
||||
|
||||
<PairLinkModal
|
||||
visible={props.isPasteLinkVisible}
|
||||
targetServerId={props.addConnectionTargetServerId ?? undefined}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
@@ -371,7 +378,6 @@ function HostsSection(props: HostsSectionProps) {
|
||||
onSave={(label) => void props.handleSaveEditDaemon(label)}
|
||||
onRemoveConnection={props.handleRemoveConnection}
|
||||
onRemoveHost={props.handleRemoveDaemon}
|
||||
onAddConnection={props.handleAddConnectionFromModal}
|
||||
restartConfirmationMessage={props.restartConfirmationMessage}
|
||||
waitForCondition={props.waitForCondition}
|
||||
isScreenMountedRef={props.isMountedRef}
|
||||
@@ -424,6 +430,123 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro
|
||||
}
|
||||
|
||||
|
||||
interface ProvidersSectionProps {
|
||||
routeServerId: string;
|
||||
}
|
||||
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[
|
||||
settingsStyles.sectionHeaderLink,
|
||||
isFetching ? { opacity: 0.5 } : null,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View key={def.id} style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.aboutErrorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.providerActions}>
|
||||
<StatusBadge
|
||||
label={
|
||||
status === "ready"
|
||||
? "Available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status === "loading"
|
||||
? "Loading..."
|
||||
: "Not installed"
|
||||
}
|
||||
variant={
|
||||
status === "ready"
|
||||
? "success"
|
||||
: status === "error"
|
||||
? "error"
|
||||
: "muted"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => setDiagnosticProvider(def.id)}
|
||||
>
|
||||
Diagnostic
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{diagnosticProvider ? (
|
||||
<ProviderDiagnosticSheet
|
||||
provider={diagnosticProvider}
|
||||
visible
|
||||
onClose={() => setDiagnosticProvider(null)}
|
||||
serverId={routeServerId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiagnosticsSectionProps {
|
||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||
isPlaybackTestRunning: boolean;
|
||||
@@ -492,6 +615,7 @@ interface SettingsSectionContentProps {
|
||||
sectionId: SettingsSectionId;
|
||||
hostsProps: HostsSectionProps;
|
||||
appearanceProps: AppearanceSectionProps;
|
||||
providersProps: ProvidersSectionProps;
|
||||
diagnosticsProps: DiagnosticsSectionProps;
|
||||
aboutProps: AboutSectionProps;
|
||||
appVersion: string | null;
|
||||
@@ -503,6 +627,7 @@ function SettingsSectionContent({
|
||||
sectionId,
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
appVersion,
|
||||
@@ -516,12 +641,18 @@ function SettingsSectionContent({
|
||||
return <AppearanceSection {...appearanceProps} />;
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsSection />;
|
||||
case "providers":
|
||||
return <ProvidersSection {...providersProps} />;
|
||||
case "diagnostics":
|
||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||
case "about":
|
||||
return <AboutSection {...aboutProps} />;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "pair-device":
|
||||
return isDesktopApp ? <PairDeviceSection /> : null;
|
||||
case "daemon":
|
||||
return isDesktopApp ? (
|
||||
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
||||
@@ -570,31 +701,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
|
||||
{sections.map((section) => {
|
||||
const isSelected = section.id === selectedSectionId;
|
||||
const IconComponent = section.icon;
|
||||
const showSeparator =
|
||||
section.id === "integrations" || section.id === "providers";
|
||||
return (
|
||||
<Pressable
|
||||
key={section.id}
|
||||
style={[
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
<View key={section.id}>
|
||||
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||
<Pressable
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -728,10 +863,6 @@ export default function SettingsScreen() {
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
@@ -810,7 +941,6 @@ export default function SettingsScreen() {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsDirectHostVisible(false);
|
||||
setIsPasteLinkVisible(false);
|
||||
setAddConnectionTargetServerId(null);
|
||||
}, []);
|
||||
|
||||
const goBackToAddConnectionMethods = useCallback(() => {
|
||||
@@ -829,24 +959,6 @@ export default function SettingsScreen() {
|
||||
handleEditDaemon(profile);
|
||||
}, [daemons, handleEditDaemon, params.editHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingEditReopenServerId) return;
|
||||
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
|
||||
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
|
||||
setPendingEditReopenServerId(null);
|
||||
setAddConnectionTargetServerId(null);
|
||||
if (profile) {
|
||||
handleEditDaemon(profile);
|
||||
}
|
||||
}, [
|
||||
daemons,
|
||||
handleEditDaemon,
|
||||
isAddHostMethodVisible,
|
||||
isDirectHostVisible,
|
||||
isPasteLinkVisible,
|
||||
pendingEditReopenServerId,
|
||||
]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(
|
||||
async (nextLabelRaw: string) => {
|
||||
if (!editingServerId) return;
|
||||
@@ -884,15 +996,6 @@ export default function SettingsScreen() {
|
||||
setPendingRemoveHost(profile);
|
||||
}, []);
|
||||
|
||||
const handleAddConnectionFromModal = useCallback(() => {
|
||||
if (!editingServerId) return;
|
||||
const serverId = editingServerId;
|
||||
setEditingDaemon(null);
|
||||
setAddConnectionTargetServerId(serverId);
|
||||
setPendingEditReopenServerId(serverId);
|
||||
setIsAddHostMethodVisible(true);
|
||||
}, [editingServerId]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(newTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: newTheme });
|
||||
@@ -938,13 +1041,10 @@ export default function SettingsScreen() {
|
||||
routeServerId,
|
||||
theme,
|
||||
handleEditDaemon,
|
||||
setAddConnectionTargetServerId,
|
||||
setPendingEditReopenServerId,
|
||||
setIsAddHostMethodVisible,
|
||||
isAddHostMethodVisible,
|
||||
isDirectHostVisible,
|
||||
isPasteLinkVisible,
|
||||
addConnectionTargetServerId,
|
||||
closeAddConnectionFlow,
|
||||
goBackToAddConnectionMethods,
|
||||
setIsDirectHostVisible,
|
||||
@@ -964,7 +1064,6 @@ export default function SettingsScreen() {
|
||||
handleSaveEditDaemon,
|
||||
handleRemoveConnection,
|
||||
handleRemoveDaemon,
|
||||
handleAddConnectionFromModal,
|
||||
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
|
||||
waitForCondition,
|
||||
isMountedRef,
|
||||
@@ -987,9 +1086,14 @@ export default function SettingsScreen() {
|
||||
isDesktopApp,
|
||||
};
|
||||
|
||||
const providersProps: ProvidersSectionProps = {
|
||||
routeServerId,
|
||||
};
|
||||
|
||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
appVersion,
|
||||
@@ -1029,7 +1133,6 @@ interface HostDetailModalProps {
|
||||
onSave: (label: string) => void;
|
||||
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
onRemoveHost: (host: HostProfile) => void;
|
||||
onAddConnection: () => void;
|
||||
restartConfirmationMessage: string;
|
||||
waitForCondition: (
|
||||
predicate: () => boolean,
|
||||
@@ -1047,7 +1150,6 @@ function HostDetailModal({
|
||||
onSave,
|
||||
onRemoveConnection,
|
||||
onRemoveHost,
|
||||
onAddConnection,
|
||||
restartConfirmationMessage,
|
||||
waitForCondition,
|
||||
isScreenMountedRef,
|
||||
@@ -1278,15 +1380,6 @@ function HostDetailModal({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={onAddConnection}
|
||||
>
|
||||
+ Add connection
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1808,6 +1901,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
providerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -1893,6 +1991,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sidebarSeparator: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[2],
|
||||
marginHorizontal: theme.spacing[3],
|
||||
},
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
subsectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
||||
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
|
||||
requestId: "req-1",
|
||||
}));
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: { closeItems },
|
||||
closeTab: async (tabId, action) => {
|
||||
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
|
||||
agentIds: ["a1"],
|
||||
terminalIds: ["t1", "t2"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
terminals: [
|
||||
{ terminalId: "t1", success: true },
|
||||
{ terminalId: "t2", success: false },
|
||||
],
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(closedTabIds).toEqual([
|
||||
"agent_a1",
|
||||
"terminal_t1",
|
||||
"terminal_t2",
|
||||
"file_/repo/README.md",
|
||||
]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
||||
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
|
||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||
const warn = vi.fn();
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: {
|
||||
closeItems: async () => {
|
||||
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
|
||||
logLabel: "others",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
};
|
||||
|
||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabDescriptor["target"];
|
||||
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
||||
return `This will archive ${agentTabs.length} agent(s).`;
|
||||
}
|
||||
|
||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
||||
}
|
||||
|
||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(
|
||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeBulkWorkspaceTabs(
|
||||
input: CloseBulkWorkspaceTabsInput,
|
||||
): Promise<CloseItemsPayload | null> {
|
||||
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
let payload: CloseItemsPayload | null = null;
|
||||
|
||||
if (hasDestructiveTabs && client) {
|
||||
try {
|
||||
payload = await client.closeItems({
|
||||
void client
|
||||
.closeItems({
|
||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||
})
|
||||
.catch((error) => {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
});
|
||||
} catch (error) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
}
|
||||
} else if (hasDestructiveTabs) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||
error: new Error("Daemon client not available"),
|
||||
});
|
||||
}
|
||||
|
||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!successfulAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
if (!successfulTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ArrowRightToLine,
|
||||
Columns2,
|
||||
Copy,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
@@ -71,6 +72,7 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -291,11 +293,14 @@ function TabChip({
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
tooltip={entry.tooltip}
|
||||
leading={(() => {
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "rotate-cw":
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
@@ -335,6 +340,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -457,6 +463,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -585,6 +592,7 @@ function ResolvedDesktopTabChip({
|
||||
normalizedWorkspaceId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -608,6 +616,7 @@ function ResolvedDesktopTabChip({
|
||||
normalizedWorkspaceId: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -630,6 +639,7 @@ function ResolvedDesktopTabChip({
|
||||
tabCount,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
@@ -644,6 +654,7 @@ function ResolvedDesktopTabChip({
|
||||
onCloseTabsToRight,
|
||||
onCopyAgentId,
|
||||
onCopyResumeCommand,
|
||||
onReloadAgent,
|
||||
tabCount,
|
||||
],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user