mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
171
Commits
| 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 | ||
|
|
9bd5f852e7 | ||
|
|
48516f0b9c | ||
|
|
0bf8e8b5b2 | ||
|
|
994ee488b9 | ||
|
|
a854096c35 | ||
|
|
5d89f9444a | ||
|
|
63905950cc | ||
|
|
ffd07ec17c | ||
|
|
2d63bc3893 | ||
|
|
55acb8a539 | ||
|
|
4c52f272fd | ||
|
|
a91f79053c | ||
|
|
7b4db04a81 | ||
|
|
ac9c2c5642 | ||
|
|
99200eabba | ||
|
|
5f2bb87a17 | ||
|
|
897c18dd5f | ||
|
|
51a865cd24 | ||
|
|
9dc3d116b4 | ||
|
|
a4326ec5c0 | ||
|
|
cc09b61b19 |
@@ -35,8 +35,43 @@ env:
|
|||||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
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:
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -130,6 +165,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
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
|
- name: Resolve release tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
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 }}"
|
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
publish-linux:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -280,7 +321,8 @@ jobs:
|
|||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
|
|
||||||
publish-windows:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
|
|||||||
+132
@@ -1,5 +1,137 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||||
|
- Searchable model favorites — quickly find and pin preferred models.
|
||||||
|
- Slash command support for OpenCode agents.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Refined model selector UX with better mobile sheet behavior.
|
||||||
|
- Workspace status now uses amber alert styling for "needs input" state.
|
||||||
|
- Themed scrollbar on message input for consistent styling.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||||
|
- Shell arguments with spaces are now properly quoted on Windows.
|
||||||
|
- Claude models with 1M context support are now correctly reported.
|
||||||
|
|
||||||
|
## 0.1.42 - 2026-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
||||||
|
|
||||||
## 0.1.41 - 2026-04-01
|
## 0.1.41 - 2026-04-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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 assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
|
- **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
|
## Debugging
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -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
|
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
|
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.
|
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.
|
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:
|
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
|
- **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
|
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||||
- **Replay old messages** — Each session derives fresh encryption keys
|
- **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
|
### 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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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)).
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Adding a New Provider to Paseo
|
||||||
|
|
||||||
|
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||||
|
|
||||||
|
## Two Integration Patterns
|
||||||
|
|
||||||
|
### ACP (Agent Client Protocol) -- recommended
|
||||||
|
|
||||||
|
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||||
|
|
||||||
|
Existing ACP providers: `claude-acp`, `copilot`.
|
||||||
|
|
||||||
|
### Direct
|
||||||
|
|
||||||
|
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||||
|
|
||||||
|
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACP Provider Checklist
|
||||||
|
|
||||||
|
### 1. Create the provider class
|
||||||
|
|
||||||
|
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
||||||
|
|
||||||
|
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
|
import { ACPAgentClient } from "./acp-agent.js";
|
||||||
|
|
||||||
|
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MY_PROVIDER_MODES: AgentMode[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
},
|
||||||
|
// Add more modes as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
type MyProviderClientOptions = {
|
||||||
|
logger: Logger;
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: MyProviderClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "my-provider", // Must match the ID used everywhere else
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||||
|
defaultModes: MY_PROVIDER_MODES,
|
||||||
|
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override isAvailable() if the provider needs specific auth/env vars
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
if (!(await super.isAvailable())) {
|
||||||
|
return false; // Binary not found
|
||||||
|
}
|
||||||
|
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
||||||
|
|
||||||
|
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: CopilotACPAgentClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "copilot",
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["copilot", "--acp"],
|
||||||
|
defaultModes: COPILOT_MODES,
|
||||||
|
capabilities: COPILOT_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
return super.isAvailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to the provider manifest
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||||
|
|
||||||
|
First, define the modes with visual metadata:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autonomous",
|
||||||
|
label: "Autonomous",
|
||||||
|
description: "Runs without prompting",
|
||||||
|
icon: "ShieldOff",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
||||||
|
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
||||||
|
|
||||||
|
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||||
|
// ... existing providers ...
|
||||||
|
{
|
||||||
|
id: "my-provider",
|
||||||
|
label: "My Provider",
|
||||||
|
description: "Short description of the provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: MY_PROVIDER_MODES,
|
||||||
|
// Optional: enable voice
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "default",
|
||||||
|
defaultModel: "some-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add the factory to the provider registry
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||||
|
|
||||||
|
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||||
|
// ... existing factories ...
|
||||||
|
"my-provider": (logger, runtimeSettings) =>
|
||||||
|
new MyProviderACPAgentClient({
|
||||||
|
logger,
|
||||||
|
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add a provider icon (app)
|
||||||
|
|
||||||
|
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface MyProviderIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
|
<Path d="..." />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
|
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||||
|
|
||||||
|
### 5. Add E2E test config
|
||||||
|
|
||||||
|
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const agentConfigs = {
|
||||||
|
// ... existing configs ...
|
||||||
|
"my-provider": {
|
||||||
|
provider: "my-provider",
|
||||||
|
model: "default-model-id",
|
||||||
|
modes: {
|
||||||
|
full: "autonomous", // Mode with no permission prompts
|
||||||
|
ask: "default", // Mode that requires permission approval
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, AgentTestConfig>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an availability check in `isProviderAvailable()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case "my-provider":
|
||||||
|
return (
|
||||||
|
isCommandAvailable("my-agent-binary") &&
|
||||||
|
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `allProviders` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const allProviders: AgentProvider[] = [
|
||||||
|
"claude",
|
||||||
|
"claude-acp",
|
||||||
|
"codex",
|
||||||
|
"copilot",
|
||||||
|
"opencode",
|
||||||
|
"my-provider",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
This is required after every change per project rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct Provider Checklist
|
||||||
|
|
||||||
|
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
||||||
|
|
||||||
|
### Interfaces to implement
|
||||||
|
|
||||||
|
**`AgentClient`** -- factory for sessions and model listing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentClient {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
// Optional:
|
||||||
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`AgentSession`** -- a running agent conversation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentSession {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly id: string | null;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||||
|
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||||
|
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||||
|
getAvailableModes(): Promise<AgentMode[]>;
|
||||||
|
getCurrentMode(): Promise<string | null>;
|
||||||
|
setMode(modeId: string): Promise<void>;
|
||||||
|
getPendingPermissions(): AgentPermissionRequest[];
|
||||||
|
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||||
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
|
interrupt(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
// Optional:
|
||||||
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||||
|
2. Add to the provider manifest (same as ACP step 2 above)
|
||||||
|
3. Add factory to the registry (same as ACP step 3 above)
|
||||||
|
4. Add icon (same as ACP step 4 above)
|
||||||
|
5. Add E2E config (same as ACP step 5 above)
|
||||||
|
6. Run typecheck
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual testing with the CLI
|
||||||
|
|
||||||
|
Start the daemon if not already running, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch an agent with your provider
|
||||||
|
paseo run --provider my-provider
|
||||||
|
|
||||||
|
# Launch with a specific model and mode
|
||||||
|
paseo run --provider my-provider --model some-model --mode default
|
||||||
|
|
||||||
|
# List running agents
|
||||||
|
paseo ls -a -g
|
||||||
|
|
||||||
|
# Check if the provider reports models
|
||||||
|
paseo models --provider my-provider
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E test patterns
|
||||||
|
|
||||||
|
The E2E configs in `agent-configs.ts` expose two helpers:
|
||||||
|
|
||||||
|
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
||||||
|
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
||||||
|
|
||||||
|
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
||||||
|
|
||||||
|
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
||||||
|
|
||||||
|
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
||||||
|
|
||||||
|
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
||||||
|
|
||||||
|
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
||||||
|
|
||||||
|
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||||
|
|
||||||
|
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||||
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
|||||||
|
|
||||||
## Standard release (patch)
|
## 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
|
```bash
|
||||||
npm run release:patch
|
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
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck # Verify the exact commit you intend to release
|
||||||
npm run release:check # Typecheck, build, dry-run pack
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:publish # Publish to npm
|
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.**
|
- **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.
|
- 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
|
## 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)
|
- [ ] 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
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||||
|
|||||||
@@ -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`
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-WUTgYClIpHYsIrKjmlxvYxM6dS9jkYtAS0WLAH2nR/I=";
|
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+93
-147
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -48,6 +48,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@agentclientprotocol/sdk": {
|
||||||
|
"version": "0.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz",
|
||||||
|
"integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ai-sdk/gateway": {
|
"node_modules/@ai-sdk/gateway": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
|
||||||
@@ -3510,6 +3519,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -15425,6 +15441,24 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": 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": {
|
"node_modules/cross-fetch": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||||
@@ -15898,18 +15932,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||||
@@ -18511,35 +18533,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/expect": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
||||||
@@ -21825,18 +21818,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/get-symbol-description": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||||
@@ -22548,15 +22529,6 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/humanize-ms": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||||
@@ -23285,6 +23257,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -26524,6 +26497,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -27430,18 +27404,6 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
@@ -27667,6 +27629,7 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mimic-fn": "^2.1.0"
|
"mimic-fn": "^2.1.0"
|
||||||
@@ -28230,6 +28193,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -31056,50 +31053,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.3",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
@@ -32032,15 +31985,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/strip-indent": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
||||||
@@ -34962,16 +34906,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.41",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.41",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.41",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -35088,11 +35032,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.41",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.41",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35133,11 +35077,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.41",
|
"@getpaseo/cli": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.41",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35171,7 +35115,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35372,7 +35316,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35398,7 +35342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35414,13 +35358,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.41",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/relay": "0.1.41",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35436,11 +35381,11 @@
|
|||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rotating-file-stream": "^3.2.9",
|
"rotating-file-stream": "^3.2.9",
|
||||||
"shell-env": "^4.0.3",
|
|
||||||
"sherpa-onnx": "1.12.28",
|
"sherpa-onnx": "1.12.28",
|
||||||
"sherpa-onnx-node": "1.12.28",
|
"sherpa-onnx-node": "1.12.28",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
@@ -35458,6 +35403,7 @@
|
|||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"tsx": "^4.6.0",
|
"tsx": "^4.6.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
@@ -35818,7 +35764,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
# Maestro takeScreenshot artifacts
|
||||||
|
*.png
|
||||||
@@ -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
|
||||||
Executable
+62
@@ -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",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.41",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.41",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.41",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.41",
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
@@ -230,6 +232,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let cancelAnyOnline: (() => void) | null = null;
|
||||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
@@ -240,28 +243,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
if (isDesktopManaged) {
|
if (isDesktopManaged) {
|
||||||
setPhase("starting-daemon");
|
setPhase("starting-daemon");
|
||||||
setError(null);
|
setError(null);
|
||||||
const bootstrapResult = await store.bootstrapDesktop();
|
|
||||||
if (!bootstrapResult.ok) {
|
let raceSettled = false;
|
||||||
if (!cancelled) {
|
|
||||||
setPhase("error");
|
const anyOnline = store.waitForAnyConnectionOnline();
|
||||||
setError(bootstrapResult.error);
|
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) {
|
const result = await Promise.race([
|
||||||
return;
|
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) {
|
if (!cancelled) {
|
||||||
setPhase("online");
|
if (result.type === "online") {
|
||||||
setError(null);
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setPhase("error");
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
@@ -288,6 +316,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
cancelAnyOnline?.();
|
||||||
};
|
};
|
||||||
}, [retryToken]);
|
}, [retryToken]);
|
||||||
|
|
||||||
@@ -340,10 +369,41 @@ function AppContainer({
|
|||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const isCompactLayout = isCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
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({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
@@ -393,14 +453,28 @@ function MobileGestureWrapper({
|
|||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
const {
|
||||||
useSidebarAnimation();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
openAgentList();
|
||||||
|
}, [openAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -443,7 +517,7 @@ function MobileGestureWrapper({
|
|||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(openAgentList)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -458,8 +532,9 @@ function MobileGestureWrapper({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
handleGestureOpen,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
openGestureRef,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
],
|
],
|
||||||
@@ -530,7 +605,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -555,6 +630,84 @@ function OfferLinkListener({
|
|||||||
return null;
|
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 }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -570,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||||
}, [activeServerId, hosts, pathname, router]);
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -674,6 +827,7 @@ export default function RootLayout() {
|
|||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<OpenProjectListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<RootStack />
|
<RootStack />
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
|
|||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
router.replace(buildHostOpenProjectRoute(serverId));
|
||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function Index() {
|
|||||||
const targetRoute = anyOnlineServerId
|
const targetRoute = anyOnlineServerId
|
||||||
? buildHostRootRoute(anyOnlineServerId)
|
? buildHostRootRoute(anyOnlineServerId)
|
||||||
: WELCOME_ROUTE;
|
: WELCOME_ROUTE;
|
||||||
router.replace(targetRoute as any);
|
router.replace(targetRoute);
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function PairScanScreen() {
|
|||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
if (source === "editHost" && targetServerId) {
|
||||||
@@ -190,7 +190,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
const settingsServerId = sourceServerId ?? serverId;
|
const settingsServerId = sourceServerId ?? serverId;
|
||||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
router.replace(buildHostSettingsRoute(settingsServerId));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId],
|
[router, source, sourceServerId, targetServerId],
|
||||||
@@ -209,7 +209,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
if (sourceServerId) {
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
|
|||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId));
|
||||||
}, [router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
maxHeight: "85%",
|
maxHeight: "85%",
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flex: 1,
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopContent: {
|
desktopContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
type DraftAgentStatusBarProps,
|
type DraftAgentStatusBarProps,
|
||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +75,8 @@ interface AgentInputAreaProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
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. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
commandDraftConfig?: DraftCommandConfig;
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||||
@@ -103,6 +106,7 @@ export function AgentInputArea({
|
|||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
|
onFocusInput,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
onComposerHeightChange,
|
onComposerHeightChange,
|
||||||
@@ -132,6 +136,8 @@ export function AgentInputArea({
|
|||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
return {
|
return {
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
|
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||||
|
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -208,6 +214,21 @@ export function AgentInputArea({
|
|||||||
onAddImages?.(addImages);
|
onAddImages?.(addImages);
|
||||||
}, [addImages, onAddImages]);
|
}, [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(
|
const submitMessage = useCallback(
|
||||||
async (text: string, images?: ImageAttachment[]) => {
|
async (text: string, images?: ImageAttachment[]) => {
|
||||||
onMessageSent?.();
|
onMessageSent?.();
|
||||||
@@ -402,6 +423,10 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (action.id) {
|
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":
|
case "message-input.focus":
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
@@ -440,8 +465,10 @@ export function AgentInputArea({
|
|||||||
handlerId: keyboardHandlerIdRef.current,
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
actions: [
|
actions: [
|
||||||
"message-input.focus",
|
"message-input.focus",
|
||||||
|
"message-input.send",
|
||||||
"message-input.dictation-toggle",
|
"message-input.dictation-toggle",
|
||||||
"message-input.dictation-cancel",
|
"message-input.dictation-cancel",
|
||||||
|
"message-input.dictation-confirm",
|
||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
@@ -612,11 +639,30 @@ export function AgentInputArea({
|
|||||||
</View>
|
</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 =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
) : (
|
) : (
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -691,6 +737,7 @@ export function AgentInputArea({
|
|||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
@@ -766,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
contextWindowMeterSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
realtimeVoiceButton: {
|
realtimeVoiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function AgentList({
|
|||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
getFeatureHighlightColor,
|
||||||
|
getFeatureTooltip,
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
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", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
|
|||||||
@@ -3,12 +3,26 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
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 { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
|
import {
|
||||||
|
buildFavoriteModelKey,
|
||||||
|
mergeProviderPreferences,
|
||||||
|
toggleFavoriteModel,
|
||||||
|
useFormPreferences,
|
||||||
|
} from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -19,28 +33,35 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
|||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type {
|
import type {
|
||||||
|
AgentFeature,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
getModeVisuals,
|
getModeVisuals,
|
||||||
type AgentModeColorTier,
|
type AgentModeColorTier,
|
||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
} from "@server/server/agent/provider-manifest";
|
} from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
|
getFeatureHighlightColor,
|
||||||
|
getFeatureTooltip,
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: 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]),
|
||||||
|
);
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -53,11 +74,20 @@ type ControlledAgentStatusBarProps = {
|
|||||||
modelOptions?: StatusOption[];
|
modelOptions?: StatusOption[];
|
||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
onSelectModel?: (modelId: string) => void;
|
onSelectModel?: (modelId: string) => void;
|
||||||
|
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||||
thinkingOptions?: StatusOption[];
|
thinkingOptions?: StatusOption[];
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
|
providerDefinitions?: AgentProviderDefinition[];
|
||||||
|
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||||
|
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 {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -77,12 +107,16 @@ export interface DraftAgentStatusBarProps {
|
|||||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
selectedThinkingOptionId: string;
|
selectedThinkingOptionId: string;
|
||||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||||
|
features?: AgentFeature[];
|
||||||
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStatusBarProps {
|
interface AgentStatusBarProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOptionLabel(
|
function findOptionLabel(
|
||||||
@@ -97,6 +131,38 @@ function findOptionLabel(
|
|||||||
return selected?.label ?? fallback;
|
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 = {
|
const MODE_ICONS = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -137,11 +203,20 @@ function ControlledStatusBar({
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
|
onSelectProviderAndModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isModelLoading = false,
|
isModelLoading = false,
|
||||||
|
providerDefinitions,
|
||||||
|
allProviderModels,
|
||||||
|
canSelectModelProvider,
|
||||||
|
favoriteKeys = new Set<string>(),
|
||||||
|
onToggleFavoriteModel,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -183,13 +258,14 @@ function ControlledStatusBar({
|
|||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
Boolean(modeOptions?.length) ||
|
Boolean(modeOptions?.length) ||
|
||||||
canSelectModel ||
|
canSelectModel ||
|
||||||
Boolean(thinkingOptions?.length);
|
Boolean(thinkingOptions?.length) ||
|
||||||
|
Boolean(features?.length);
|
||||||
|
|
||||||
if (!hasAnyControl) {
|
if (!hasAnyControl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
const modelDisabled = disabled;
|
||||||
|
|
||||||
const SEARCH_THRESHOLD = 6;
|
const SEARCH_THRESHOLD = 6;
|
||||||
|
|
||||||
@@ -205,6 +281,26 @@ function ControlledStatusBar({
|
|||||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[modelOptions],
|
[modelOptions],
|
||||||
);
|
);
|
||||||
|
const fallbackAllProviderModels = useMemo(() => {
|
||||||
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
|
if (!modelOptions || modelOptions.length === 0) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(
|
||||||
|
provider,
|
||||||
|
modelOptions.map((option) => ({
|
||||||
|
provider: provider as AgentProvider,
|
||||||
|
id: option.id,
|
||||||
|
label: option.label,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return map;
|
||||||
|
}, [modelOptions, provider]);
|
||||||
|
const effectiveProviderDefinitions = providerDefinitions ??
|
||||||
|
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||||
|
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||||
|
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[thinkingOptions],
|
[thinkingOptions],
|
||||||
@@ -240,8 +336,11 @@ function ControlledStatusBar({
|
|||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||||
setOpenSelector(nextOpen ? selector : null);
|
setOpenSelector(nextOpen ? selector : null);
|
||||||
|
if (!nextOpen) {
|
||||||
|
onDropdownClose?.();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onDropdownClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectorPress = useCallback(
|
const handleSelectorPress = useCallback(
|
||||||
@@ -289,49 +388,37 @@ function ControlledStatusBar({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<>
|
<Tooltip
|
||||||
<Tooltip
|
key={`model-${displayModel}`}
|
||||||
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
delayDuration={0}
|
||||||
delayDuration={0}
|
enabledOnDesktop
|
||||||
enabledOnDesktop
|
enabledOnMobile={false}
|
||||||
enabledOnMobile={false}
|
>
|
||||||
>
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<View>
|
||||||
<Pressable
|
<CombinedModelSelector
|
||||||
ref={modelAnchorRef}
|
providerDefinitions={effectiveProviderDefinitions}
|
||||||
collapsable={false}
|
allProviderModels={effectiveAllProviderModels}
|
||||||
|
selectedProvider={provider}
|
||||||
|
selectedModel={selectedModelId ?? ""}
|
||||||
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
|
if (selectedProviderId === provider) {
|
||||||
|
onSelectModel?.(modelId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onPress={() => handleSelectorPress("model")}
|
onClose={onDropdownClose}
|
||||||
style={({ pressed, hovered }) => [
|
/>
|
||||||
styles.modeBadge,
|
</View>
|
||||||
hovered && styles.modeBadgeHovered,
|
</TooltipTrigger>
|
||||||
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
modelDisabled && styles.disabledBadge,
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||||
]}
|
</TooltipContent>
|
||||||
accessibilityRole="button"
|
</Tooltip>
|
||||||
accessibilityLabel="Select agent model"
|
|
||||||
testID="agent-model-selector"
|
|
||||||
>
|
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Combobox
|
|
||||||
options={comboboxModelOptions}
|
|
||||||
value={selectedModelId ?? ""}
|
|
||||||
onSelect={(id) => onSelectModel?.(id)}
|
|
||||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
|
||||||
open={openSelector === "model"}
|
|
||||||
onOpenChange={handleOpenChange("model")}
|
|
||||||
anchorRef={modelAnchorRef}
|
|
||||||
desktopPlacement="top-start"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
@@ -428,6 +515,105 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -454,73 +640,44 @@ function ControlledStatusBar({
|
|||||||
stackBehavior="replace"
|
stackBehavior="replace"
|
||||||
testID="agent-preferences-sheet"
|
testID="agent-preferences-sheet"
|
||||||
>
|
>
|
||||||
{providerOptions && providerOptions.length > 0 ? (
|
|
||||||
<View style={styles.sheetSection}>
|
|
||||||
<DropdownMenu
|
|
||||||
open={openSelector === "provider"}
|
|
||||||
onOpenChange={handleOpenChange("provider")}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled || !canSelectProvider}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.sheetSelect,
|
|
||||||
pressed && styles.sheetSelectPressed,
|
|
||||||
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Select agent provider"
|
|
||||||
testID="agent-preferences-provider"
|
|
||||||
>
|
|
||||||
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="top" align="start">
|
|
||||||
{providerOptions.map((provider) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={provider.id}
|
|
||||||
selected={provider.id === selectedProviderId}
|
|
||||||
onSelect={() => onSelectProvider?.(provider.id)}
|
|
||||||
>
|
|
||||||
{provider.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<CombinedModelSelector
|
||||||
open={openSelector === "model"}
|
providerDefinitions={effectiveProviderDefinitions}
|
||||||
onOpenChange={handleOpenChange("model")}
|
allProviderModels={effectiveAllProviderModels}
|
||||||
>
|
selectedProvider={provider}
|
||||||
<DropdownMenuTrigger
|
selectedModel={selectedModelId ?? ""}
|
||||||
disabled={modelDisabled}
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
style={({ pressed }) => [
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
styles.sheetSelect,
|
if (onSelectProviderAndModel) {
|
||||||
pressed && styles.sheetSelectPressed,
|
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||||
modelDisabled && styles.disabledSheetSelect,
|
} else {
|
||||||
]}
|
if (selectedProviderId !== provider) {
|
||||||
accessibilityRole="button"
|
onSelectProvider?.(selectedProviderId);
|
||||||
accessibilityLabel="Select agent model"
|
}
|
||||||
testID="agent-preferences-model"
|
onSelectModel?.(modelId);
|
||||||
>
|
}
|
||||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
}}
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
favoriteKeys={favoriteKeys}
|
||||||
</DropdownMenuTrigger>
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
<DropdownMenuContent side="top" align="start">
|
isLoading={isModelLoading}
|
||||||
{(modelOptions ?? []).map((model) => (
|
disabled={modelDisabled}
|
||||||
<DropdownMenuItem
|
onClose={onDropdownClose}
|
||||||
key={model.id}
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
selected={model.id === selectedModelId}
|
<View
|
||||||
onSelect={() => onSelectModel?.(model.id)}
|
style={[
|
||||||
>
|
styles.sheetSelect,
|
||||||
{model.label}
|
modelDisabled && styles.disabledSheetSelect,
|
||||||
</DropdownMenuItem>
|
]}
|
||||||
))}
|
pointerEvents="none"
|
||||||
</DropdownMenuContent>
|
testID="agent-preferences-model"
|
||||||
</DropdownMenu>
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -541,6 +698,7 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Select thinking option"
|
accessibilityLabel="Select thinking option"
|
||||||
testID="agent-preferences-thinking"
|
testID="agent-preferences-thinking"
|
||||||
>
|
>
|
||||||
|
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -601,6 +759,83 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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>
|
</AdaptiveModalSheet>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -610,7 +845,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
const EMPTY_MODES: AgentMode[] = [];
|
const EMPTY_MODES: AgentMode[] = [];
|
||||||
|
|
||||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
const agent = useSessionStore(
|
const agent = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
@@ -622,7 +857,9 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
currentModeId: currentAgent.currentModeId,
|
currentModeId: currentAgent.currentModeId,
|
||||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
|
features: currentAgent.features,
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
|
lastUsage: currentAgent.lastUsage,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
@@ -634,23 +871,34 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const {
|
||||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
entries: snapshotEntries,
|
||||||
enabled: Boolean(client && agent?.provider),
|
isLoading: snapshotIsLoading,
|
||||||
staleTime: 5 * 60 * 1000,
|
isFetching: snapshotIsFetching,
|
||||||
queryFn: async () => {
|
} = useProvidersSnapshot(serverId);
|
||||||
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 models = modelsQuery.data ?? null;
|
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 agentProviderModels = useMemo(() => {
|
||||||
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
|
if (agent?.provider && snapshotModels) {
|
||||||
|
map.set(agent.provider, snapshotModels);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [agent?.provider, snapshotModels]);
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -674,6 +922,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
const favoriteKeys = useMemo(
|
||||||
|
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||||
|
[preferences.favoriteModels],
|
||||||
|
);
|
||||||
|
|
||||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||||
@@ -693,6 +945,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
|
providerDefinitions={agentProviderDefinitions}
|
||||||
|
allProviderModels={agentProviderModels}
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +976,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
|
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||||
onSelectThinkingOption={(thinkingOptionId) => {
|
onSelectThinkingOption={(thinkingOptionId) => {
|
||||||
@@ -749,7 +1009,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
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}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -772,9 +1042,13 @@ export function DraftAgentStatusBar({
|
|||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
if (modeOptions.length === 0) {
|
if (modeOptions.length === 0) {
|
||||||
@@ -789,6 +1063,10 @@ export function DraftAgentStatusBar({
|
|||||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||||
}, [thinkingOptions]);
|
}, [thinkingOptions]);
|
||||||
|
const favoriteKeys = useMemo(
|
||||||
|
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||||
|
[preferences.favoriteModels],
|
||||||
|
);
|
||||||
|
|
||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
@@ -803,8 +1081,15 @@ export function DraftAgentStatusBar({
|
|||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onToggleFavorite={(provider, modelId) => {
|
||||||
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
@@ -814,40 +1099,48 @@ export function DraftAgentStatusBar({
|
|||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
|
features={features}
|
||||||
|
onSetFeature={onSetFeature}
|
||||||
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = providerDefinitions.map((definition) => ({
|
const modelOptions: StatusOption[] = models.map((model) => ({
|
||||||
id: definition.id,
|
id: model.id,
|
||||||
label: definition.label,
|
label: model.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = [];
|
|
||||||
for (const model of models) {
|
|
||||||
modelOptions.push({ id: model.id, label: model.label });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledStatusBar
|
<>
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerOptions={providerOptions}
|
provider={selectedProvider}
|
||||||
selectedProviderId={selectedProvider}
|
providerDefinitions={providerDefinitions}
|
||||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
allProviderModels={allProviderModels}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={mappedModeOptions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={selectedModel}
|
selectedModelId={selectedModel}
|
||||||
onSelectModel={onSelectModel}
|
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||||
isModelLoading={isModelLoading}
|
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
isModelLoading={isAllModelsLoading}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
favoriteKeys={favoriteKeys}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
disabled={disabled}
|
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 ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||||
|
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
||||||
|
|
||||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||||
switch (selector) {
|
switch (selector) {
|
||||||
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
|||||||
return normalized;
|
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: {
|
export function resolveAgentModelSelection(input: {
|
||||||
models: AgentModelDefinition[] | null;
|
models: AgentModelDefinition[] | null;
|
||||||
runtimeModelId: string | null | undefined;
|
runtimeModelId: string | null | undefined;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -27,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
|
SpeakMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -36,9 +36,13 @@ import {
|
|||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
|
import { PlanCard } from "./plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
|
AgentPermissionResponse,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
@@ -59,9 +63,7 @@ import {
|
|||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -180,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "file", path: normalized.file },
|
target: { kind: "file", path: normalized.file },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +254,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||||
return looseGap;
|
return looseGap;
|
||||||
}
|
}
|
||||||
if (
|
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
|
||||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
|
||||||
isToolSequenceItem(belowItem)
|
|
||||||
) {
|
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||||
@@ -269,44 +268,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[looseGap, tightGap],
|
[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(
|
const renderStreamItemContent = useCallback(
|
||||||
(
|
(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
@@ -368,7 +329,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "thought": {
|
case "thought": {
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -400,6 +360,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
if (payload.source === "agent") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
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 (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
@@ -738,6 +713,29 @@ function PermissionRequestCard({
|
|||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
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(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -755,90 +753,6 @@ function PermissionRequestCard({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [request]);
|
}, [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({
|
const permissionMutation = useMutation({
|
||||||
mutationFn: async (input: {
|
mutationFn: async (input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -862,11 +776,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingAction(null);
|
setRespondingActionId(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -880,6 +794,24 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[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") {
|
if (request.kind === "question") {
|
||||||
return (
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -909,18 +914,7 @@ function PermissionRequestCard({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{planMarkdown ? (
|
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
|
||||||
<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}
|
|
||||||
|
|
||||||
{!isPlanRequest ? (
|
{!isPlanRequest ? (
|
||||||
<ToolCallDetailsContent
|
<ToolCallDetailsContent
|
||||||
@@ -935,78 +929,7 @@ function PermissionRequestCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text
|
{footer}
|
||||||
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>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
buildModelRows,
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
matchesSearch,
|
||||||
|
resolveProviderLabel,
|
||||||
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
|
describe("combined model selector helpers", () => {
|
||||||
|
const providerDefinitions = [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "sonnet-4.6",
|
||||||
|
label: "Sonnet 4.6",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const codexModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps enough data to search by model and provider name", async () => {
|
||||||
|
const rows = buildModelRows(providerDefinitions, new Map([
|
||||||
|
["claude", claudeModels],
|
||||||
|
["codex", codexModels],
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
|
||||||
|
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||||
|
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||||
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
|
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
|
|
||||||
|
export type SelectorModelRow = FavoriteModelRow;
|
||||||
|
|
||||||
|
export function resolveProviderLabel(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
providerId: string,
|
||||||
|
): string {
|
||||||
|
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||||
|
return modelLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelRows(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||||
|
): SelectorModelRow[] {
|
||||||
|
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
|
||||||
|
const rows: SelectorModelRow[] = [];
|
||||||
|
|
||||||
|
for (const definition of providerDefinitions) {
|
||||||
|
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
||||||
|
for (const model of allProviderModels.get(definition.id) ?? []) {
|
||||||
|
rows.push({
|
||||||
|
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||||
|
provider: definition.id,
|
||||||
|
providerLabel,
|
||||||
|
modelId: model.id,
|
||||||
|
modelLabel: model.label,
|
||||||
|
description: model.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||||
|
value.toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||||
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
||||||
|
|
||||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||||
|
|
||||||
@@ -133,8 +133,11 @@ export function DraggableList<T>({
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
const items = dragItems ?? data;
|
const items = dragItems ?? data;
|
||||||
|
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||||
|
enabled: showCustomScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -177,7 +180,6 @@ export function DraggableList<T>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
|
||||||
const wrapperStyle = [
|
const wrapperStyle = [
|
||||||
{ position: "relative" as const },
|
{ position: "relative" as const },
|
||||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||||
@@ -193,12 +195,10 @@ export function DraggableList<T>({
|
|||||||
style={style}
|
style={style}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
onScroll={scrollbar.onScroll}
|
||||||
}
|
scrollEventThrottle={16}
|
||||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
|
||||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
|
||||||
>
|
>
|
||||||
{ListHeaderComponent}
|
{ListHeaderComponent}
|
||||||
{items.length === 0 && ListEmptyComponent}
|
{items.length === 0 && ListEmptyComponent}
|
||||||
@@ -259,13 +259,7 @@ export function DraggableList<T>({
|
|||||||
{ListFooterComponent}
|
{ListFooterComponent}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={showCustomScrollbar}
|
|
||||||
metrics={scrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||||
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
|
||||||
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
|
|||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
closeToAgent();
|
||||||
|
}, [closeToAgent, gestureAnimatingRef]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
|
|||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)("swipe-close-gesture");
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleClose,
|
handleCloseFromGesture,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
|
|||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
||||||
<Pressable
|
|
||||||
style={styles.backdropPressable}
|
|
||||||
onPress={() => handleClose("backdrop-press")}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.mobileSidebar,
|
explorerStaticStyles.mobileSidebar,
|
||||||
{ width: windowWidth, paddingTop: insets.top },
|
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
||||||
sidebarAnimatedStyle,
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
]}
|
]}
|
||||||
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
<GestureDetector gesture={resizeGesture}>
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
<View
|
<GestureDetector gesture={resizeGesture}>
|
||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
<View
|
||||||
/>
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
</GestureDetector>
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
activeTab={explorerTab}
|
activeTab={explorerTab}
|
||||||
onTabPress={handleTabPress}
|
onTabPress={handleTabPress}
|
||||||
onClose={() => handleClose("desktop-close-button")}
|
onClose={() => handleClose("desktop-close-button")}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</Animated.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: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...RNStyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute",
|
position: "absolute" as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
overflow: "hidden" as const,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative",
|
position: "relative" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
desktopSidebarBorder: {
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: theme.colors.border,
|
borderLeftColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeScrollEvent,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
@@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
|
|||||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import {
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
WebDesktopScrollbarOverlay,
|
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "@/components/web-desktop-scrollbar";
|
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
{ value: "name", label: "Name" },
|
{ value: "name", label: "Name" },
|
||||||
@@ -124,7 +118,6 @@ export function FileExplorerPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
@@ -135,6 +128,16 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
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 directories = explorerState?.directories ?? new Map();
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
@@ -150,16 +153,16 @@ export function FileExplorerPane({
|
|||||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
setExpandedPaths(new Set(["."]));
|
}, [workspaceStateKey]);
|
||||||
}, [actionsWorkspaceStateKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -173,23 +176,35 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: 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)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
if (!selectedEntryPath || !workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
const parentDir = getParentDirectory(selectedEntryPath);
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
const ancestors = getAncestorDirectories(parentDir);
|
||||||
|
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||||
setExpandedPaths((prev) => {
|
if (newPaths.length === 0) {
|
||||||
const next = new Set(prev);
|
return;
|
||||||
ancestors.forEach((path) => next.add(path));
|
}
|
||||||
return next;
|
setExpandedPathsForWorkspace(
|
||||||
});
|
workspaceStateKey,
|
||||||
|
[...Array.from(expandedPaths), ...newPaths],
|
||||||
ancestors.forEach((path) => {
|
);
|
||||||
|
newPaths.forEach((path) => {
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -197,34 +212,46 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
}, [
|
||||||
|
directories,
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
requestDirectoryListing,
|
||||||
|
selectedEntryPath,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
const nextExpanded = !isExpanded;
|
if (isExpanded) {
|
||||||
setExpandedPaths((prev) => {
|
setExpandedPathsForWorkspace(
|
||||||
const next = new Set(prev);
|
workspaceStateKey,
|
||||||
if (isExpanded) {
|
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||||
next.delete(entry.path);
|
);
|
||||||
} else {
|
} else {
|
||||||
next.add(entry.path);
|
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(
|
const handleOpenFile = useCallback(
|
||||||
@@ -502,24 +529,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
||||||
|
|
||||||
const handleTreeListScroll = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
treeScrollbarMetrics.onScroll(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTreeListLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => {
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
treeScrollbarMetrics.onLayout(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -598,27 +607,16 @@ export function FileExplorerPane({
|
|||||||
keyExtractor={(row) => row.entry.path}
|
keyExtractor={(row) => row.entry.path}
|
||||||
testID="file-explorer-tree-scroll"
|
testID="file-explorer-tree-scroll"
|
||||||
contentContainerStyle={styles.entriesContent}
|
contentContainerStyle={styles.entriesContent}
|
||||||
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
|
||||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
initialNumToRender={24}
|
initialNumToRender={24}
|
||||||
maxToRenderPerBatch={40}
|
maxToRenderPerBatch={40}
|
||||||
windowSize={12}
|
windowSize={12}
|
||||||
/>
|
/>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={showDesktopWebScrollbar}
|
|
||||||
metrics={treeScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
treeListRef.current?.scrollToOffset({
|
|
||||||
offset: nextOffset,
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -7,17 +7,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
Platform,
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeScrollEvent,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import {
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
WebDesktopScrollbarOverlay,
|
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "@/components/web-desktop-scrollbar";
|
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -123,9 +117,10 @@ function FilePreviewBody({
|
|||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
|
||||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text") {
|
if (!preview || preview.kind !== "text") {
|
||||||
@@ -140,24 +135,6 @@ function FilePreviewBody({
|
|||||||
return lineNumberGutterWidth(highlightedLines.length);
|
return lineNumberGutterWidth(highlightedLines.length);
|
||||||
}, [highlightedLines]);
|
}, [highlightedLines]);
|
||||||
|
|
||||||
const handlePreviewScroll = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
if (enablePreviewDesktopScrollbar) {
|
|
||||||
previewScrollbarMetrics.onScroll(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreviewLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => {
|
|
||||||
if (enablePreviewDesktopScrollbar) {
|
|
||||||
previewScrollbarMetrics.onLayout(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -197,13 +174,11 @@ function FilePreviewBody({
|
|||||||
<RNScrollView
|
<RNScrollView
|
||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||||
@@ -218,13 +193,7 @@ function FilePreviewBody({
|
|||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
)}
|
)}
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={enablePreviewDesktopScrollbar}
|
|
||||||
metrics={previewScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -236,13 +205,11 @@ function FilePreviewBody({
|
|||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
contentContainerStyle={styles.previewImageScrollContent}
|
contentContainerStyle={styles.previewImageScrollContent}
|
||||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
@@ -252,13 +219,7 @@ function FilePreviewBody({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={enablePreviewDesktopScrollbar}
|
|
||||||
metrics={previewScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface CopilotIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
|
||||||
|
<Path
|
||||||
|
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
|
||||||
|
fillRule="nonzero"
|
||||||
|
/>
|
||||||
|
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface OpenCodeIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||||
|
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
|
||||||
|
<Path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} 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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -222,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
router.push(buildHostSettingsRoute(activeServerId));
|
||||||
}, [activeServerId, closeToAgent]);
|
}, [activeServerId, closeToAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
router.push(buildHostSettingsRoute(activeServerId));
|
||||||
}, [activeServerId]);
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleViewMoreNavigate = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSessionsRoute(activeServerId) as any);
|
router.push(buildHostSessionsRoute(activeServerId));
|
||||||
}, [activeServerId]);
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleHostSelect = useCallback(
|
const handleHostSelect = useCallback(
|
||||||
@@ -246,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
}
|
}
|
||||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||||
setIsHostPickerOpen(false);
|
setIsHostPickerOpen(false);
|
||||||
router.push(nextPath as any);
|
router.push(nextPath);
|
||||||
},
|
},
|
||||||
[pathname],
|
[pathname],
|
||||||
);
|
);
|
||||||
@@ -370,14 +377,16 @@ function MobileSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const closeTouchStartX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0);
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
}, [closeToAgent]);
|
}, [closeToAgent, gestureAnimatingRef]);
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
@@ -452,7 +461,7 @@ function MobileSidebar({
|
|||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -471,7 +480,7 @@ function MobileSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
handleClose,
|
handleCloseFromGesture,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -498,13 +507,11 @@ function MobileSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
|
||||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -526,7 +533,7 @@ function MobileSidebar({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={closeToAgent}
|
onWorkspacePress={() => closeToAgent()}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
@@ -688,7 +695,8 @@ function DesktopSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
{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)]}
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
/>
|
/>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
</Animated.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: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...RNStyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute",
|
position: "absolute" as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
overflow: "hidden" as const,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
|
desktopSidebar: {
|
||||||
|
position: "relative" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebarBorder: {
|
||||||
position: "relative",
|
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
|
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
|
||||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||||
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
|||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +79,8 @@ export interface MessageInputProps {
|
|||||||
isInputActive?: boolean;
|
isInputActive?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
|
/** Content to render on the right side 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) */
|
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||||
rightContent?: React.ReactNode;
|
rightContent?: React.ReactNode;
|
||||||
voiceServerId?: string;
|
voiceServerId?: string;
|
||||||
@@ -91,7 +102,7 @@ export interface MessageInputProps {
|
|||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
|
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
|
||||||
/**
|
/**
|
||||||
* Web-only: return the underlying DOM element for focus assertions/retries.
|
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||||
* May return null if not mounted or on native.
|
* May return null if not mounted or on native.
|
||||||
@@ -192,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isInputActive = true,
|
isInputActive = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
|
beforeVoiceContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
@@ -233,26 +245,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
runKeyboardAction: (action) => {
|
runKeyboardAction: (action) => {
|
||||||
if (action === "focus") {
|
if (action === "focus") {
|
||||||
textInputRef.current?.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") {
|
if (action === "voice-toggle") {
|
||||||
handleToggleRealtimeVoiceShortcut();
|
handleToggleRealtimeVoiceShortcut();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "voice-mute-toggle") {
|
if (action === "voice-mute-toggle") {
|
||||||
if (isRealtimeVoiceForCurrentAgent) {
|
if (isRealtimeVoiceForCurrentAgent) {
|
||||||
voice?.toggleMute();
|
voice?.toggleMute();
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "dictation-cancel") {
|
if (action === "dictation-cancel") {
|
||||||
if (isDictatingRef.current) {
|
if (isDictatingRef.current) {
|
||||||
cancelDictation();
|
cancelDictation();
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "dictation-toggle") {
|
if (action === "dictation-toggle") {
|
||||||
@@ -262,7 +283,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
} else {
|
} else {
|
||||||
void startDictationIfAvailable();
|
void startDictationIfAvailable();
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
getNativeElement: () => {
|
getNativeElement: () => {
|
||||||
if (!IS_WEB) return null;
|
if (!IS_WEB) return null;
|
||||||
@@ -570,6 +594,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (IS_WEB) {
|
||||||
|
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||||
|
}
|
||||||
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
|
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||||
|
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
||||||
const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
|
const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
|
||||||
if (!ref) return null;
|
if (!ref) return null;
|
||||||
@@ -911,42 +947,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text input */}
|
{/* Text input */}
|
||||||
<TextInput
|
<View style={styles.textInputScrollWrapper}>
|
||||||
ref={textInputRef}
|
<TextInput
|
||||||
value={value}
|
ref={textInputRef}
|
||||||
onChangeText={handleInputChange}
|
value={value}
|
||||||
placeholder={placeholder}
|
onChangeText={handleInputChange}
|
||||||
placeholderTextColor={theme.colors.surface4}
|
placeholder={placeholder}
|
||||||
accessibilityLabel="Message agent..."
|
placeholderTextColor={theme.colors.surface4}
|
||||||
onFocus={() => {
|
accessibilityLabel="Message agent..."
|
||||||
isInputFocusedRef.current = true;
|
onFocus={() => {
|
||||||
onFocusChange?.(true);
|
isInputFocusedRef.current = true;
|
||||||
}}
|
onFocusChange?.(true);
|
||||||
onBlur={() => {
|
}}
|
||||||
isInputFocusedRef.current = false;
|
onBlur={() => {
|
||||||
onFocusChange?.(false);
|
isInputFocusedRef.current = false;
|
||||||
}}
|
onFocusChange?.(false);
|
||||||
style={[
|
}}
|
||||||
styles.textInput,
|
style={[
|
||||||
IS_WEB
|
styles.textInput,
|
||||||
? {
|
IS_WEB
|
||||||
height: inputHeight,
|
? {
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
height: inputHeight,
|
||||||
maxHeight: MAX_INPUT_HEIGHT,
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
}
|
maxHeight: MAX_INPUT_HEIGHT,
|
||||||
: {
|
}
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
: {
|
||||||
maxHeight: MAX_INPUT_HEIGHT,
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
},
|
maxHeight: MAX_INPUT_HEIGHT,
|
||||||
]}
|
},
|
||||||
multiline
|
]}
|
||||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
multiline
|
||||||
onContentSizeChange={handleContentSizeChange}
|
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
onSelectionChange={handleSelectionChange}
|
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||||
autoFocus={IS_WEB && autoFocus}
|
onSelectionChange={handleSelectionChange}
|
||||||
/>
|
autoFocus={IS_WEB && autoFocus}
|
||||||
|
/>
|
||||||
|
{inputScrollbar}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Button row */}
|
{/* Button row */}
|
||||||
<View style={styles.buttonRow}>
|
<View style={styles.buttonRow}>
|
||||||
@@ -979,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||||
<View style={styles.rightButtonGroup}>
|
<View style={styles.rightButtonGroup}>
|
||||||
|
{beforeVoiceContent}
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
@@ -1187,6 +1227,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
removeImageButtonVisible: {
|
removeImageButtonVisible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
textInputScrollWrapper: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
|
MicVocal,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -71,6 +72,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
@@ -716,13 +718,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: AssistantMessageProps) {
|
}: 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 { theme, rt } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
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 {
|
interface ActivityLogProps {
|
||||||
type: "system" | "info" | "success" | "error" | "artifact";
|
type: "system" | "info" | "success" | "error" | "artifact";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1764,9 +1818,6 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
onInlineDetailsHoverChange,
|
onInlineDetailsHoverChange,
|
||||||
onInlineDetailsExpandedChange,
|
onInlineDetailsExpandedChange,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
// DEBUG: log when ToolCall actually renders (inside memo boundary)
|
|
||||||
console.log("[ToolCall] render", { toolName, status });
|
|
||||||
|
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -1884,6 +1935,12 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
);
|
);
|
||||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||||
|
|
||||||
|
if (effectiveDetail?.type === "plan") {
|
||||||
|
return (
|
||||||
|
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
testID="tool-call-badge"
|
testID="tool-call-badge"
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Bot } from "lucide-react-native";
|
import { Bot } from "lucide-react-native";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
|
import { 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> = {
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
|
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 {
|
export function getProviderIcon(provider: string): typeof Bot {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
if (!allAnswered || isResponding) return;
|
||||||
setRespondingAction("submit");
|
setRespondingAction("submit");
|
||||||
const answers: Record<string, string> = {};
|
const answers: Record<string, string> = {};
|
||||||
for (let i = 0; i < questions!.length; i++) {
|
for (let i = 0; i < questions!.length; i++) {
|
||||||
@@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={otherText}
|
value={otherText}
|
||||||
onChangeText={(text) => setOtherText(qIndex, text)}
|
onChangeText={(text) => setOtherText(qIndex, text)}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
editable={!isResponding}
|
editable={!isResponding}
|
||||||
|
blurOnSubmit={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
CircleAlert,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
@@ -52,13 +54,7 @@ import {
|
|||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import {
|
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
useContextMenu,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -73,7 +69,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
|||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
@@ -100,11 +96,20 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
|||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
const EMPTY_WORKSPACES = new Map();
|
const EMPTY_WORKSPACES = new Map();
|
||||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
open: "Open",
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
merged: "Merged",
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||||
closed: "Closed",
|
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||||
};
|
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 {
|
interface SidebarWorkspaceListProps {
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
@@ -140,6 +145,8 @@ interface ProjectHeaderRowProps {
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
isArchiving?: boolean;
|
isArchiving?: boolean;
|
||||||
menuController: ReturnType<typeof useContextMenu> | null;
|
menuController: ReturnType<typeof useContextMenu> | null;
|
||||||
|
onRemoveProject?: () => void;
|
||||||
|
removeProjectStatus?: "idle" | "pending";
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +174,8 @@ interface WorkspaceRowInnerProps {
|
|||||||
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
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) => {
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -195,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
|||||||
pressed && styles.workspacePrBadgePressed,
|
pressed && styles.workspacePrBadgePressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<GitPullRequest size={12} color={activeColor} />
|
<GitPullRequest size={12} color={iconColor} />
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.workspacePrBadgeText,
|
styles.workspacePrBadgeText,
|
||||||
{ color: activeColor },
|
{ color: textColor },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
|
#{hint.number}
|
||||||
</Text>
|
</Text>
|
||||||
{isHovered && <ExternalLink size={10} color={activeColor} />}
|
{isHovered && <ExternalLink size={10} color={textColor} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,6 +246,14 @@ function WorkspaceStatusIndicator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bucket === "needs_input") {
|
||||||
|
return (
|
||||||
|
<View style={styles.workspaceStatusDot}>
|
||||||
|
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const KindIcon =
|
const KindIcon =
|
||||||
workspaceKind === "local_checkout"
|
workspaceKind === "local_checkout"
|
||||||
? Monitor
|
? Monitor
|
||||||
@@ -247,6 +263,13 @@ function WorkspaceStatusIndicator({
|
|||||||
if (!KindIcon) return null;
|
if (!KindIcon) return null;
|
||||||
|
|
||||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||||
|
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
|
||||||
|
? EMPHASIZED_STATUS_DOT_SIZE
|
||||||
|
: DEFAULT_STATUS_DOT_SIZE;
|
||||||
|
const statusDotOffset =
|
||||||
|
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||||
|
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||||
|
: DEFAULT_STATUS_DOT_OFFSET;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot}>
|
<View style={styles.workspaceStatusDot}>
|
||||||
@@ -258,6 +281,10 @@ function WorkspaceStatusIndicator({
|
|||||||
{
|
{
|
||||||
backgroundColor: dotColor,
|
backgroundColor: dotColor,
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
|
width: statusDotSize,
|
||||||
|
height: statusDotSize,
|
||||||
|
right: statusDotOffset,
|
||||||
|
bottom: statusDotOffset,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -327,11 +354,26 @@ function ProjectLeadingVisual({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeWorkspace.statusBucket === "needs_input") {
|
||||||
|
return (
|
||||||
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
|
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dotColor = getStatusDotColor({
|
const dotColor = getStatusDotColor({
|
||||||
theme,
|
theme,
|
||||||
bucket: activeWorkspace.statusBucket,
|
bucket: activeWorkspace.statusBucket,
|
||||||
showDoneAsInactive: false,
|
showDoneAsInactive: false,
|
||||||
});
|
});
|
||||||
|
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
|
||||||
|
? EMPHASIZED_STATUS_DOT_SIZE
|
||||||
|
: DEFAULT_STATUS_DOT_SIZE;
|
||||||
|
const statusDotOffset =
|
||||||
|
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||||
|
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||||
|
: DEFAULT_STATUS_DOT_OFFSET;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectLeadingVisualSlot}>
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
@@ -343,6 +385,10 @@ function ProjectLeadingVisual({
|
|||||||
{
|
{
|
||||||
backgroundColor: dotColor,
|
backgroundColor: dotColor,
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
|
width: statusDotSize,
|
||||||
|
height: statusDotSize,
|
||||||
|
right: statusDotOffset,
|
||||||
|
bottom: statusDotOffset,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -661,8 +707,11 @@ function ProjectHeaderRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
isArchiving = false,
|
isArchiving = false,
|
||||||
menuController,
|
menuController,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus = "idle",
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint = isCompactFormFactor();
|
const isMobileBreakpoint = isCompactFormFactor();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
@@ -749,16 +798,55 @@ function ProjectHeaderRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{canCreateWorktree ? (
|
<View style={styles.projectTrailingActions}>
|
||||||
<NewWorktreeButton
|
{canCreateWorktree ? (
|
||||||
displayName={displayName}
|
<NewWorktreeButton
|
||||||
onPress={() => createWorktreeMutation.mutate()}
|
displayName={displayName}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
onPress={() => createWorktreeMutation.mutate()}
|
||||||
loading={createWorktreeMutation.isPending}
|
visible={isHovered || isMobileBreakpoint}
|
||||||
showShortcutHint={isProjectActive}
|
loading={createWorktreeMutation.isPending}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
showShortcutHint={isProjectActive}
|
||||||
/>
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
) : null}
|
/>
|
||||||
|
) : 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 ? (
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
<View style={styles.shortcutBadge}>
|
<View style={styles.shortcutBadge}>
|
||||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
@@ -1025,7 +1113,7 @@ function WorkspaceRowWithMenu({
|
|||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
archivedWorkspaceId: workspace.workspaceId,
|
||||||
workspaces: sessionWorkspaces.values(),
|
workspaces: sessionWorkspaces.values(),
|
||||||
}) as any,
|
}),
|
||||||
);
|
);
|
||||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
@@ -1166,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({
|
function FlattenedProjectRow({
|
||||||
project,
|
project,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -1335,6 +1269,8 @@ function FlattenedProjectRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1350,25 +1286,9 @@ function FlattenedProjectRow({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
isProjectActive?: boolean;
|
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 (
|
return (
|
||||||
<ProjectHeaderRow
|
<ProjectHeaderRow
|
||||||
project={project}
|
project={project}
|
||||||
@@ -1388,6 +1308,8 @@ function FlattenedProjectRow({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
|
onRemoveProject={onRemoveProject}
|
||||||
|
removeProjectStatus={removeProjectStatus}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1558,6 +1480,48 @@ function ProjectBlock({
|
|||||||
[onWorkspaceReorder, project.projectKey],
|
[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 (
|
return (
|
||||||
<View style={styles.projectBlock}>
|
<View style={styles.projectBlock}>
|
||||||
{rowModel.kind === "workspace_link" ? (
|
{rowModel.kind === "workspace_link" ? (
|
||||||
@@ -1582,6 +1546,8 @@ function ProjectBlock({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
|
onRemoveProject={handleRemoveProject}
|
||||||
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1600,7 +1566,10 @@ function ProjectBlock({
|
|||||||
onWorktreeCreated={onWorktreeCreated}
|
onWorktreeCreated={onWorktreeCreated}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
|
isArchiving={isRemovingProject}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
|
onRemoveProject={handleRemoveProject}
|
||||||
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2101,6 +2070,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
projectIconActionButtonHidden: {
|
projectIconActionButtonHidden: {
|
||||||
opacity: 0,
|
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: {
|
projectTrailingControlSlot: {
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -2184,10 +2173,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
statusDotOverlay: {
|
statusDotOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 0,
|
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||||
bottom: 0,
|
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||||
width: 7,
|
width: DEFAULT_STATUS_DOT_SIZE,
|
||||||
height: 7,
|
height: DEFAULT_STATUS_DOT_SIZE,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ interface SplitContainerProps {
|
|||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (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;
|
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
@@ -176,18 +177,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
}: MountedTabSlotProps) {
|
}: 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(
|
const content = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -265,6 +254,7 @@ export function SplitContainer({
|
|||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -534,6 +524,7 @@ export function SplitContainer({
|
|||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -656,6 +647,7 @@ function SplitNodeView({
|
|||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -691,6 +683,7 @@ function SplitNodeView({
|
|||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -741,6 +734,7 @@ function SplitNodeView({
|
|||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -790,6 +784,7 @@ function SplitPaneView({
|
|||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -901,6 +896,7 @@ function SplitPaneView({
|
|||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
testID="agent-chat-scroll"
|
testID="agent-chat-scroll"
|
||||||
nativeID="agent-chat-scroll-native-virtualized"
|
nativeID="agent-chat-scroll-native-virtualized"
|
||||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||||
contentContainerStyle={baseListContentContainerStyle}
|
contentContainerStyle={baseListContentContainerStyle}
|
||||||
style={listStyle}
|
style={listStyle}
|
||||||
onLayout={handleListLayout}
|
onLayout={handleListLayout}
|
||||||
|
|||||||
@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
|||||||
const USER_SCROLL_DELTA_EPSILON = 1;
|
const USER_SCROLL_DELTA_EPSILON = 1;
|
||||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||||
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style";
|
import { useWebElementScrollbar } from "./use-web-scrollbar";
|
||||||
const WEB_STREAM_SCROLLBAR_STYLE = `
|
|
||||||
#agent-chat-scroll-web-dom-scroll,
|
|
||||||
#agent-chat-scroll-web-dom-virtualized {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
|
|
||||||
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
|
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
|
||||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||||
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollEnabled,
|
scrollEnabled,
|
||||||
isMobileBreakpoint,
|
isMobileBreakpoint,
|
||||||
} = props;
|
} = props;
|
||||||
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
|
||||||
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
|
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
const contentRef = useRef<HTMLElement | null>(null);
|
const contentRef = useRef<HTMLElement | null>(null);
|
||||||
const [followOutput, setFollowOutputr] = useState(true);
|
const [followOutput, setFollowOutputr] = useState(true);
|
||||||
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const lastTouchClientYRef = useRef<number | null>(null);
|
const lastTouchClientYRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
|
||||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||||
|
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
contentRef,
|
||||||
|
});
|
||||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||||
const {
|
const {
|
||||||
renderHistoryVirtualizedRow,
|
renderHistoryVirtualizedRow,
|
||||||
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
onNearBottomChange(true);
|
onNearBottomChange(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamScrollbarMetrics.onContentSizeChange(
|
|
||||||
scrollContainer.clientWidth,
|
|
||||||
scrollContainer.scrollHeight,
|
|
||||||
);
|
|
||||||
streamScrollbarMetrics.onLayout({
|
|
||||||
nativeEvent: {
|
|
||||||
layout: {
|
|
||||||
width: scrollContainer.clientWidth,
|
|
||||||
height: scrollContainer.clientHeight,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
streamScrollbarMetrics.onScroll({
|
|
||||||
nativeEvent: {
|
|
||||||
contentOffset: { x: 0, y: scrollContainer.scrollTop },
|
|
||||||
contentSize: {
|
|
||||||
width: scrollContainer.clientWidth,
|
|
||||||
height: scrollContainer.scrollHeight,
|
|
||||||
},
|
|
||||||
layoutMeasurement: {
|
|
||||||
width: scrollContainer.clientWidth,
|
|
||||||
height: scrollContainer.clientHeight,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||||
const currentMetrics = {
|
const currentMetrics = {
|
||||||
scrollTop: scrollContainer.scrollTop,
|
scrollTop: scrollContainer.scrollTop,
|
||||||
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
...currentMetrics,
|
...currentMetrics,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]);
|
}, [onNearBottomChange, props.agentId]);
|
||||||
|
|
||||||
const handleDomScroll = useCallback(() => {
|
const handleDomScroll = useCallback(() => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
|
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
scrollContainerRef.current = node;
|
scrollContainerRef.current = node;
|
||||||
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbarOverlay}
|
||||||
enabled={showDesktopWebScrollbar}
|
|
||||||
metrics={streamScrollbarMetrics}
|
|
||||||
inverted={false}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
const scrollContainer = scrollContainerRef.current;
|
|
||||||
if (!scrollContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
|
|
||||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
|
||||||
updateScrollMetrics();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface ComboboxProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
enableDismissOnClose?: boolean;
|
enableDismissOnClose?: boolean;
|
||||||
|
stackBehavior?: "push" | "switch" | "replace";
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
/**
|
/**
|
||||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
@@ -72,6 +73,12 @@ export interface ComboboxProps {
|
|||||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||||
*/
|
*/
|
||||||
desktopPreventInitialFlash?: boolean;
|
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>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -145,8 +152,12 @@ export interface ComboboxItemProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
kind?: "directory" | "file";
|
kind?: "directory" | "file";
|
||||||
leadingSlot?: ReactNode;
|
leadingSlot?: ReactNode;
|
||||||
|
trailingSlot?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
||||||
|
elevated?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -156,8 +167,11 @@ export function ComboboxItem({
|
|||||||
description,
|
description,
|
||||||
kind,
|
kind,
|
||||||
leadingSlot,
|
leadingSlot,
|
||||||
|
trailingSlot,
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
|
disabled,
|
||||||
|
elevated,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}: ComboboxItemProps): ReactElement {
|
}: ComboboxItemProps): ReactElement {
|
||||||
@@ -178,28 +192,33 @@ export function ComboboxItem({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.comboboxItem,
|
styles.comboboxItem,
|
||||||
hovered && styles.comboboxItemHovered,
|
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||||
pressed && styles.comboboxItemPressed,
|
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
|
disabled && styles.comboboxItemDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{selected ? (
|
{selected || trailingSlot ? (
|
||||||
<View style={styles.comboboxItemTrailingSlot}>
|
<View style={styles.comboboxItemTrailingContainer}>
|
||||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
<View style={styles.comboboxItemTrailingSlot}>
|
||||||
|
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
|
||||||
|
</View>
|
||||||
|
{trailingSlot}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -233,8 +252,12 @@ export function Combobox({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
enableDismissOnClose,
|
enableDismissOnClose,
|
||||||
|
stackBehavior,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
|
desktopMinWidth,
|
||||||
|
desktopFixedHeight,
|
||||||
|
stickyHeader,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
@@ -379,12 +402,27 @@ export function Combobox({
|
|||||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
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
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: floatingLeft ?? 0,
|
||||||
bottom: desktopAboveSearchBottom ?? 0,
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
}
|
}
|
||||||
: floatingStyles;
|
: useStableBottom
|
||||||
|
? {
|
||||||
|
left: floatingLeft ?? 0,
|
||||||
|
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||||
|
}
|
||||||
|
: floatingStyles;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
@@ -642,6 +680,7 @@ export function Combobox({
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableDismissOnClose={enableDismissOnClose}
|
enableDismissOnClose={enableDismissOnClose}
|
||||||
|
stackBehavior={stackBehavior}
|
||||||
backgroundComponent={ComboboxSheetBackground}
|
backgroundComponent={ComboboxSheetBackground}
|
||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
@@ -650,6 +689,7 @@ export function Combobox({
|
|||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{stickyHeader}
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={styles.comboboxScrollContent}
|
contentContainerStyle={styles.comboboxScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -675,13 +715,16 @@ export function Combobox({
|
|||||||
styles.desktopContainer,
|
styles.desktopContainer,
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
minWidth: referenceWidth ?? 200,
|
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
||||||
maxWidth: 400,
|
maxWidth: Math.max(400, desktopMinWidth ?? 0),
|
||||||
},
|
},
|
||||||
|
desktopFixedHeight != null
|
||||||
|
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
||||||
|
: null,
|
||||||
desktopPositionStyle,
|
desktopPositionStyle,
|
||||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||||
typeof availableSize?.height === "number"
|
typeof availableSize?.height === "number"
|
||||||
? { maxHeight: Math.min(availableSize.height, 400) }
|
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
|
||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
@@ -689,14 +732,17 @@ export function Combobox({
|
|||||||
onLayout={() => update()}
|
onLayout={() => update()}
|
||||||
>
|
>
|
||||||
{children ? (
|
{children ? (
|
||||||
<ScrollView
|
<>
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
{stickyHeader}
|
||||||
keyboardShouldPersistTaps="handled"
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
contentContainerStyle={styles.desktopChildrenScrollContent}
|
||||||
style={styles.desktopScroll}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{content}
|
style={styles.desktopScroll}
|
||||||
</ScrollView>
|
>
|
||||||
|
{content}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{effectiveOptionsPosition === "above-search" ? (
|
{effectiveOptionsPosition === "above-search" ? (
|
||||||
@@ -771,22 +817,41 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
comboboxItemHovered: {
|
comboboxItemHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemHoveredElevated: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
comboboxItemPressed: {
|
comboboxItemPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemPressedElevated: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
comboboxItemActive: {
|
comboboxItemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemDisabled: {
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
comboboxItemTrailingSlot: {
|
comboboxItemTrailingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
comboboxItemTrailingContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
},
|
},
|
||||||
comboboxItemContent: {
|
comboboxItemContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
comboboxItemContentInline: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
comboboxItemLeadingSlot: {
|
comboboxItemLeadingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -797,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
comboboxItemDescription: {
|
comboboxItemDescription: {
|
||||||
marginTop: 2,
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -856,6 +921,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
desktopScrollContent: {
|
desktopScrollContent: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
desktopChildrenScrollContent: {
|
||||||
|
// No padding — custom children (e.g. model selector) control their own spacing
|
||||||
|
},
|
||||||
desktopScrollContentAboveSearch: {
|
desktopScrollContentAboveSearch: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
// Keep parity with dropdown-menu action statuses.
|
// Keep parity with dropdown-menu action statuses.
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -595,6 +596,7 @@ export function ContextMenuItem({
|
|||||||
successLabel,
|
successLabel,
|
||||||
closeOnSelect = true,
|
closeOnSelect = true,
|
||||||
testID,
|
testID,
|
||||||
|
tooltip,
|
||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
description?: string;
|
description?: string;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
@@ -612,6 +614,7 @@ export function ContextMenuItem({
|
|||||||
successLabel?: string;
|
successLabel?: string;
|
||||||
closeOnSelect?: boolean;
|
closeOnSelect?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
tooltip?: string;
|
||||||
}>): ReactElement {
|
}>): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { setOpen } = useContextMenuContext("ContextMenuItem");
|
const { setOpen } = useContextMenuContext("ContextMenuItem");
|
||||||
@@ -642,7 +645,7 @@ export function ContextMenuItem({
|
|||||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
) : null);
|
) : null);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -704,6 +707,19 @@ export function ContextMenuItem({
|
|||||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||||
</Pressable>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
tooltipText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
// Action status for menu items with loading/success feedback
|
// Action status for menu items with loading/success feedback
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
|
|||||||
successLabel,
|
successLabel,
|
||||||
closeOnSelect = true,
|
closeOnSelect = true,
|
||||||
testID,
|
testID,
|
||||||
|
tooltip,
|
||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
description?: string;
|
description?: string;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
|
|||||||
successLabel?: string;
|
successLabel?: string;
|
||||||
closeOnSelect?: boolean;
|
closeOnSelect?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
tooltip?: string;
|
||||||
}>): ReactElement {
|
}>): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
||||||
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
|
|||||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
) : null);
|
) : null);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
|
|||||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||||
</Pressable>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
tooltipText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -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;
|
setOpen: (open: boolean) => void;
|
||||||
triggerRef: React.RefObject<View | null>;
|
triggerRef: React.RefObject<View | null>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
openOnPress: boolean;
|
||||||
delayDuration: number;
|
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({
|
function computePosition({
|
||||||
triggerRect,
|
triggerRect,
|
||||||
contentSize,
|
contentSize,
|
||||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWeb = Platform.OS === "web";
|
const isMobile = isMobileTooltipEnvironment();
|
||||||
const isMobileWeb =
|
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||||
isWeb &&
|
|
||||||
typeof navigator !== "undefined" &&
|
|
||||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
|
||||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
|
||||||
|
|
||||||
const value = useMemo<TooltipContextValue>(
|
const value = useMemo<TooltipContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
|||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
enabled,
|
enabled,
|
||||||
|
openOnPress: isMobile,
|
||||||
delayDuration,
|
delayDuration,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen, enabled, delayDuration],
|
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||||
@@ -323,9 +333,17 @@ export function TooltipTrigger({
|
|||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
onPress?.(e);
|
onPress?.(e);
|
||||||
|
if (!ctx.enabled || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.openOnPress) {
|
||||||
|
clearOpenTimer();
|
||||||
|
ctx.setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
[close, onPress],
|
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
@@ -492,7 +510,7 @@ export function TooltipContent({
|
|||||||
statusBarTranslucent={Platform.OS === "android"}
|
statusBarTranslucent={Platform.OS === "android"}
|
||||||
onRequestClose={() => ctx.setOpen(false)}
|
onRequestClose={() => ctx.setOpen(false)}
|
||||||
>
|
>
|
||||||
<View pointerEvents="box-none" style={styles.overlay}>
|
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
entering={FadeIn.duration(80)}
|
entering={FadeIn.duration(80)}
|
||||||
@@ -513,7 +531,7 @@ export function TooltipContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
type FlatList,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
type ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import {
|
||||||
|
WebDesktopScrollbarOverlay,
|
||||||
|
useWebDesktopScrollbarMetrics,
|
||||||
|
type ScrollbarMetrics,
|
||||||
|
} from "./web-desktop-scrollbar";
|
||||||
|
|
||||||
|
const METRICS_EPSILON = 0.5;
|
||||||
|
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||||
|
|
||||||
|
function ensureHideScrollbarStyle(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = HIDE_SCROLLBAR_STYLE_ID;
|
||||||
|
style.textContent =
|
||||||
|
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
|
||||||
|
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
|
||||||
|
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM element scrollbar ────────────────────────────────────────────
|
||||||
|
// Fully automatic: listens to scroll/input/resize events on the element,
|
||||||
|
// hides the native scrollbar, and returns a themed overlay or null.
|
||||||
|
|
||||||
|
export function useWebElementScrollbar(
|
||||||
|
elementRef: RefObject<HTMLElement | null>,
|
||||||
|
options?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
contentRef?: RefObject<HTMLElement | null>;
|
||||||
|
},
|
||||||
|
): ReactNode {
|
||||||
|
const isWeb = Platform.OS === "web";
|
||||||
|
const enabled = (options?.enabled ?? true) && isWeb;
|
||||||
|
const contentRef = options?.contentRef;
|
||||||
|
|
||||||
|
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||||
|
offset: 0,
|
||||||
|
viewportSize: 0,
|
||||||
|
contentSize: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const element = elementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.setAttribute("data-hide-scrollbar", "");
|
||||||
|
(element.style as any).scrollbarWidth = "none";
|
||||||
|
(element.style as any).msOverflowStyle = "none";
|
||||||
|
ensureHideScrollbarStyle();
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const next: ScrollbarMetrics = {
|
||||||
|
offset: el.scrollTop,
|
||||||
|
viewportSize: el.clientHeight,
|
||||||
|
contentSize: el.scrollHeight,
|
||||||
|
};
|
||||||
|
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener("scroll", update, { passive: true });
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(update);
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
const contentElement = contentRef?.current;
|
||||||
|
if (contentElement) {
|
||||||
|
resizeObserver.observe(contentElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("scroll", update);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
element.removeAttribute("data-hide-scrollbar");
|
||||||
|
(element.style as any).scrollbarWidth = "";
|
||||||
|
(element.style as any).msOverflowStyle = "";
|
||||||
|
};
|
||||||
|
}, [contentRef, elementRef, enabled]);
|
||||||
|
|
||||||
|
const onScrollToOffset = useCallback(
|
||||||
|
(offset: number) => {
|
||||||
|
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
|
||||||
|
},
|
||||||
|
[elementRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
||||||
|
// Returns event handlers to wire onto your ScrollView/FlatList plus
|
||||||
|
// a renderable overlay. The overlay is null when disabled.
|
||||||
|
|
||||||
|
interface WebScrollViewScrollbar {
|
||||||
|
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||||
|
onLayout: (event: LayoutChangeEvent) => void;
|
||||||
|
onContentSizeChange: (width: number, height: number) => void;
|
||||||
|
overlay: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebScrollViewScrollbar(
|
||||||
|
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||||
|
options?: { enabled?: boolean },
|
||||||
|
): WebScrollViewScrollbar {
|
||||||
|
const isWeb = Platform.OS === "web";
|
||||||
|
const enabled = (options?.enabled ?? true) && isWeb;
|
||||||
|
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||||
|
|
||||||
|
const onScrollToOffset = useCallback(
|
||||||
|
(offset: number) => {
|
||||||
|
const scrollable = scrollableRef.current;
|
||||||
|
if (!scrollable) return;
|
||||||
|
if ("scrollToOffset" in scrollable) {
|
||||||
|
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
||||||
|
} else {
|
||||||
|
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollableRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlay: ReactNode = enabled ? (
|
||||||
|
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onScroll: metricsHook.onScroll,
|
||||||
|
onLayout: metricsHook.onLayout,
|
||||||
|
onContentSizeChange: metricsHook.onContentSizeChange,
|
||||||
|
overlay,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrollbarMetrics = {
|
export type ScrollbarMetrics = {
|
||||||
offset: number;
|
offset: number;
|
||||||
viewportSize: number;
|
viewportSize: number;
|
||||||
contentSize: number;
|
contentSize: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
|||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
type WelcomeAction = {
|
type WelcomeAction = {
|
||||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||||
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
fontSize: theme.fontSize.sm,
|
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: {
|
versionLabel: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -250,12 +270,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
if (pendingNameHost) {
|
if (pendingNameHost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
@@ -326,6 +346,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||||
</Text>
|
</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}>
|
<View style={styles.actions}>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: 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 translateX = useSharedValue(initialTargets.translateX);
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const gestureAnimatingRef = useRef(false);
|
||||||
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
return;
|
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
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||||
|
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
processTimelineResponse,
|
processTimelineResponse,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type Agent,
|
type Agent,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
|
mergeWorkspaceSnapshotWithExisting,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
@@ -67,6 +69,22 @@ export type {
|
|||||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
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"];
|
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||||
|
|
||||||
interface BufferedAudioChunk {
|
interface BufferedAudioChunk {
|
||||||
@@ -309,6 +327,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||||
|
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
let includeSubscribe = options?.subscribe ?? false;
|
let includeSubscribe = options?.subscribe ?? false;
|
||||||
|
|
||||||
@@ -324,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
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) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
@@ -349,6 +374,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
const current = prev.get(agent.id);
|
const current = prev.get(agent.id);
|
||||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
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;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -603,6 +637,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
updateSessionClient(serverId, client);
|
updateSessionClient(serverId, client);
|
||||||
}, [serverId, client, updateSessionClient]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!voiceRuntime) {
|
if (!voiceRuntime) {
|
||||||
return;
|
return;
|
||||||
@@ -1105,6 +1167,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
hostname: serverInfo.hostname,
|
hostname: serverInfo.hostname,
|
||||||
version: serverInfo.version,
|
version: serverInfo.version,
|
||||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||||
|
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: 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 translateX = useSharedValue(initialTargets.translateX);
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const gestureAnimatingRef = useRef(false);
|
||||||
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
return;
|
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
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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 Clipboard from "expo-clipboard";
|
||||||
import * as QRCode from "qrcode";
|
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -10,30 +9,26 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Terminal,
|
|
||||||
Copy,
|
Copy,
|
||||||
FileText,
|
FileText,
|
||||||
Smartphone,
|
Activity,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||||
import {
|
import {
|
||||||
getCliSymlinkInstructions,
|
getCliDaemonStatus,
|
||||||
getDesktopDaemonLogs,
|
getDesktopDaemonLogs,
|
||||||
getDesktopDaemonPairing,
|
|
||||||
getDesktopDaemonStatus,
|
getDesktopDaemonStatus,
|
||||||
restartDesktopDaemon,
|
restartDesktopDaemon,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
startDesktopDaemon,
|
startDesktopDaemon,
|
||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
type CliSymlinkInstructions,
|
|
||||||
type DesktopDaemonLogs,
|
type DesktopDaemonLogs,
|
||||||
type DesktopDaemonStatus,
|
type DesktopDaemonStatus,
|
||||||
type DesktopPairingOffer,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
@@ -50,28 +45,22 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
const [statusError, setStatusError] = useState<string | null>(null);
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
|
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
|
||||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
|
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
|
|
||||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
|
||||||
useState<CliSymlinkInstructions | null>(null);
|
|
||||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadDaemonData = useCallback(() => {
|
const loadDaemonData = useCallback(() => {
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
|
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||||
.then(([status, logs, version]) => {
|
.then(([status, logs]) => {
|
||||||
setDaemonStatus(status);
|
setDaemonStatus(status);
|
||||||
setDaemonLogs(logs);
|
setDaemonLogs(logs);
|
||||||
setDaemonVersion(version.version);
|
setDaemonVersion(status.version);
|
||||||
setStatusError(null);
|
setStatusError(null);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -218,40 +207,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
updateSettings,
|
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 handleCopyLogPath = useCallback(() => {
|
||||||
const logPath = daemonLogs?.logPath;
|
const logPath = daemonLogs?.logPath;
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
@@ -275,45 +230,32 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
setIsLogsModalOpen(true);
|
setIsLogsModalOpen(true);
|
||||||
}, [daemonLogs]);
|
}, [daemonLogs]);
|
||||||
|
|
||||||
const handleOpenPairingModal = useCallback(() => {
|
const handleOpenCliStatus = useCallback(async () => {
|
||||||
if (isLoadingPairing) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
void Clipboard.setStringAsync(cliStatusOutput)
|
||||||
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)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Alert.alert("Copied", "Pairing link copied.");
|
Alert.alert("Copied", "Status copied to clipboard.");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to copy pairing link", error);
|
console.error("[Settings] Failed to copy daemon status", error);
|
||||||
Alert.alert("Error", "Unable to copy pairing link.");
|
|
||||||
});
|
});
|
||||||
}, [pairingOffer?.url]);
|
}, [cliStatusOutput]);
|
||||||
|
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -321,14 +263,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={settingsStyles.sectionHeader}>
|
||||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||||
textStyle={styles.sectionLinkText}
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||||
style={styles.sectionLink}
|
style={settingsStyles.sectionHeaderLink}
|
||||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||||
accessibilityLabel="Open advanced daemon settings"
|
accessibilityLabel="Open advanced daemon settings"
|
||||||
>
|
>
|
||||||
@@ -336,10 +278,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<View style={styles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={styles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Status</Text>
|
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statusValueGroup}>
|
<View style={styles.statusValueGroup}>
|
||||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||||
@@ -348,10 +290,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</View>
|
</View>
|
||||||
{showLifecycleControls ? (
|
{showLifecycleControls ? (
|
||||||
<>
|
<>
|
||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||||
<Text style={styles.hintText}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
{isDaemonManagementPaused
|
{isDaemonManagementPaused
|
||||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||||
@@ -379,10 +321,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
: "Pause"}
|
: "Pause"}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
@@ -401,26 +343,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
|
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</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>
|
</View>
|
||||||
<View style={styles.actionGroup}>
|
<View style={styles.actionGroup}>
|
||||||
{daemonLogs?.logPath ? (
|
{daemonLogs?.logPath ? (
|
||||||
@@ -444,18 +370,21 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Pair device</Text>
|
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Runs `paseo daemon status` and shows the output.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
onPress={handleOpenPairingModal}
|
onPress={() => void handleOpenCliStatus()}
|
||||||
|
disabled={isLoadingCliStatus}
|
||||||
>
|
>
|
||||||
Pair device
|
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -469,47 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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
|
<AdaptiveModalSheet
|
||||||
visible={isLogsModalOpen}
|
visible={isLogsModalOpen}
|
||||||
onClose={() => setIsLogsModalOpen(false)}
|
onClose={() => setIsLogsModalOpen(false)}
|
||||||
@@ -518,151 +406,41 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
snapPoints={["70%", "92%"]}
|
snapPoints={["70%", "92%"]}
|
||||||
>
|
>
|
||||||
<View style={styles.modalBody}>
|
<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>
|
<Text style={styles.logOutput} selectable>
|
||||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
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) => ({
|
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: {
|
actionGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
@@ -673,10 +451,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
rowTitle: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
},
|
|
||||||
valueText: {
|
valueText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -686,11 +460,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
hintText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
statusText: {
|
statusText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -713,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[2],
|
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: {
|
logOutput: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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;
|
hostname: string | null;
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
home: string;
|
home: string;
|
||||||
|
version: string | null;
|
||||||
|
desktopManaged: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
|
|||||||
qr: string | null;
|
qr: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CliSymlinkInstructions = {
|
|
||||||
title: string;
|
|
||||||
detail: string;
|
|
||||||
commands: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LocalTransportTarget = {
|
export type LocalTransportTarget = {
|
||||||
transportType: "socket" | "pipe";
|
transportType: "socket" | "pipe";
|
||||||
transportPath: string;
|
transportPath: string;
|
||||||
@@ -86,6 +82,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
|||||||
hostname: toStringOrNull(raw.hostname),
|
hostname: toStringOrNull(raw.hostname),
|
||||||
pid: toNumberOrNull(raw.pid),
|
pid: toNumberOrNull(raw.pid),
|
||||||
home: toStringOrNull(raw.home) ?? "",
|
home: toStringOrNull(raw.home) ?? "",
|
||||||
|
version: toStringOrNull(raw.version),
|
||||||
|
desktopManaged: raw.desktopManaged === true,
|
||||||
error: toStringOrNull(raw.error),
|
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 {
|
export function shouldUseDesktopDaemon(): boolean {
|
||||||
return isElectronRuntime();
|
return isElectronRuntime();
|
||||||
}
|
}
|
||||||
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
|||||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
export async function getCliDaemonStatus(): Promise<string> {
|
||||||
const instructions = parseCliSymlinkInstructionsInternal(raw);
|
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||||
if (!instructions) {
|
if (typeof raw !== "string") {
|
||||||
throw new Error("Unexpected CLI symlink instructions response.");
|
throw new Error("Unexpected CLI daemon status response.");
|
||||||
}
|
}
|
||||||
return instructions;
|
return raw;
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
|
|
||||||
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalTransportEventUnlisten = () => void;
|
export type LocalTransportEventUnlisten = () => void;
|
||||||
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
|
|||||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
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 {
|
export interface DesktopHostBridge {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
invoke?: DesktopInvokeBridge["invoke"];
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
|
getPendingOpenProject?: () => Promise<string | null>;
|
||||||
events?: DesktopEventsBridge;
|
events?: DesktopEventsBridge;
|
||||||
window?: DesktopWindowModuleBridge;
|
window?: DesktopWindowModuleBridge;
|
||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
|
|||||||
@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
|
|||||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||||
const model = draftConfig.model?.trim() ?? "";
|
const model = draftConfig.model?.trim() ?? "";
|
||||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||||
|
const featureValues = draftConfig.featureValues;
|
||||||
return {
|
return {
|
||||||
provider: draftConfig.provider,
|
provider: draftConfig.provider,
|
||||||
cwd,
|
cwd,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
|
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
|
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
|
||||||
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
|
|||||||
draftConfig?.modeId ?? null,
|
draftConfig?.modeId ?? null,
|
||||||
draftConfig?.model ?? null,
|
draftConfig?.model ?? null,
|
||||||
draftConfig?.thinkingOptionId ?? null,
|
draftConfig?.thinkingOptionId ?? null,
|
||||||
|
draftConfig?.featureValues ?? null,
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
type AgentProviderDefinition,
|
type AgentProviderDefinition,
|
||||||
@@ -8,9 +7,10 @@ import type {
|
|||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -84,6 +84,7 @@ type UseAgentFormStateResult = {
|
|||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
agentDefinition?: AgentProviderDefinition;
|
agentDefinition?: AgentProviderDefinition;
|
||||||
|
allProviderEntries?: ProviderSnapshotEntry[];
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
availableModels: AgentModelDefinition[];
|
availableModels: AgentModelDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
@@ -368,116 +369,61 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Session state for provider model listing
|
const {
|
||||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
entries: snapshotEntries,
|
||||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
isLoading: snapshotIsLoading,
|
||||||
|
isFetching: snapshotIsFetching,
|
||||||
|
error: snapshotError,
|
||||||
|
refresh: refreshSnapshot,
|
||||||
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const availableProvidersQuery = useQuery({
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
queryKey: ["availableProviders", formState.serverId],
|
const snapshotProviderDefinitions = useMemo(() => {
|
||||||
enabled: Boolean(
|
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||||
),
|
}, [snapshotEntries]);
|
||||||
staleTime: 60 * 1000,
|
const snapshotProviderDefinitionMap = useMemo(
|
||||||
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(
|
|
||||||
() =>
|
() =>
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
|
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||||
),
|
),
|
||||||
[providerDefinitions],
|
[snapshotProviderDefinitions],
|
||||||
);
|
);
|
||||||
|
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
const readyProviders = new Set(
|
||||||
useEffect(() => {
|
(snapshotEntries ?? [])
|
||||||
const trimmed = formState.workingDir.trim();
|
.filter((entry) => entry.status === "ready")
|
||||||
const next = trimmed.length > 0 ? trimmed : undefined;
|
.map((entry) => entry.provider),
|
||||||
const timer = setTimeout(() => setDebouncedCwd(next), 180);
|
);
|
||||||
return () => clearTimeout(timer);
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
}, [formState.workingDir]);
|
snapshotProviderDefinitions
|
||||||
|
.filter((definition) => readyProviders.has(definition.id))
|
||||||
const providerModelsQuery = useQuery({
|
.map((definition) => [definition.id, definition]),
|
||||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
);
|
||||||
enabled: Boolean(
|
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||||
isVisible &&
|
const snapshotAllProviderModels = useMemo(() => {
|
||||||
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 map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
for (let i = 0; i < providerDefinitions.length; i++) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
const query = allProviderModelQueries[i];
|
map.set(entry.provider, entry.models ?? []);
|
||||||
if (query?.data) {
|
|
||||||
map.set(providerDefinitions[i]!.id, query.data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [allProviderModelQueries, providerDefinitions]);
|
}, [snapshotEntries]);
|
||||||
|
const snapshotSelectedEntry = useMemo(
|
||||||
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
|
() => (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
|
// Combine initialValues with initialServerId for resolution
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||||
@@ -502,7 +448,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
userModified,
|
userModified,
|
||||||
formStateRef.current,
|
formStateRef.current,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
providerDefinitionMap,
|
selectableProviderDefinitionMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update if something changed
|
// Only update if something changed
|
||||||
@@ -527,7 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
providerDefinitionMap,
|
selectableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
// Auto-select the first online host when:
|
||||||
@@ -567,8 +513,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
(provider: AgentProvider) => {
|
(provider: AgentProvider) => {
|
||||||
|
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
const isValidModel = (m: string) =>
|
const isValidModel = (m: string) =>
|
||||||
@@ -606,12 +555,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
[
|
||||||
|
allProviderModels,
|
||||||
|
preferences?.providerPreferences,
|
||||||
|
selectableProviderDefinitionMap,
|
||||||
|
updatePreferences,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(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 providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
@@ -631,7 +588,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
},
|
},
|
||||||
[allProviderModels, providerDefinitionMap, updatePreferences],
|
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
@@ -688,8 +645,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
void providerModelsQuery.refetch();
|
refreshSnapshot();
|
||||||
}, [providerModelsQuery]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
@@ -722,13 +679,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
const modeOptions = agentDefinition?.modes ?? [];
|
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||||
const modelError =
|
const modelError = snapshotError;
|
||||||
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
|
|
||||||
|
|
||||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||||
|
|
||||||
@@ -751,6 +706,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
providerDefinitionMap,
|
providerDefinitionMap,
|
||||||
agentDefinition,
|
agentDefinition,
|
||||||
|
allProviderEntries,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels: availableModels ?? [],
|
availableModels: availableModels ?? [],
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -781,6 +737,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
providerDefinitionMap,
|
providerDefinitionMap,
|
||||||
agentDefinition,
|
agentDefinition,
|
||||||
|
allProviderEntries,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
|
|
||||||
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
|
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
|
||||||
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
|
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
|
||||||
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
|
|
||||||
|
|
||||||
function createAgent(id: string): Agent {
|
function createAgent(id: string): Agent {
|
||||||
const now = new Date("2026-02-19T00:00:00.000Z");
|
const now = new Date("2026-02-19T00:00:00.000Z");
|
||||||
@@ -74,7 +73,6 @@ function createBaseMemory(
|
|||||||
return {
|
return {
|
||||||
hasRenderedReady: false,
|
hasRenderedReady: false,
|
||||||
lastReadyAgent: null,
|
lastReadyAgent: null,
|
||||||
activeToastLatch: "none",
|
|
||||||
hadInitialSyncFailure: false,
|
hadInitialSyncFailure: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
|
|||||||
return state.sync;
|
return state.sync;
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
|
function expectSyncErrorSync(state: ReadyState): void {
|
||||||
expect(state.sync.status).toBe("sync_error");
|
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", () => {
|
describe("deriveAgentScreenViewState", () => {
|
||||||
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
const sync = expectCatchingUpSync(ready);
|
const sync = expectCatchingUpSync(ready);
|
||||||
|
|
||||||
expect(sync.ui).toBe("overlay");
|
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({
|
const memory = createBaseMemory({
|
||||||
hasRenderedReady: true,
|
hasRenderedReady: true,
|
||||||
lastReadyAgent: createAgent("agent-1"),
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
const ready = expectReadyState(result.state);
|
const ready = expectReadyState(result.state);
|
||||||
const sync = expectCatchingUpSync(ready);
|
const sync = expectCatchingUpSync(ready);
|
||||||
|
|
||||||
expect(sync.ui).toBe("toast");
|
expect(sync.ui).toBe("silent");
|
||||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps sync errors non-blocking once the screen was ready", () => {
|
it("keeps sync errors non-blocking once the screen was ready", () => {
|
||||||
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
|
|
||||||
const result = deriveAgentScreenViewState({ input, memory });
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
const ready = expectReadyState(result.state);
|
const ready = expectReadyState(result.state);
|
||||||
const sync = expectSyncErrorSync(ready);
|
expectSyncErrorSync(ready);
|
||||||
|
|
||||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
|
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
|
||||||
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
memory: initialMemory,
|
memory: initialMemory,
|
||||||
});
|
});
|
||||||
const errorReady = expectReadyState(errorResult.state);
|
const errorReady = expectReadyState(errorResult.state);
|
||||||
const errorSync = expectSyncErrorSync(errorReady);
|
expectSyncErrorSync(errorReady);
|
||||||
|
|
||||||
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
|
|
||||||
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
|
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
|
||||||
|
|
||||||
const retryInput: AgentScreenMachineInput = {
|
const retryInput: AgentScreenMachineInput = {
|
||||||
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
const retrySync = expectCatchingUpSync(retryReady);
|
const retrySync = expectCatchingUpSync(retryReady);
|
||||||
|
|
||||||
expect(retrySync.ui).toBe("silent");
|
expect(retrySync.ui).toBe("silent");
|
||||||
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
|
|
||||||
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
|
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
|
|
||||||
const result = deriveAgentScreenViewState({ input, memory });
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
const ready = expectReadyState(result.state);
|
const ready = expectReadyState(result.state);
|
||||||
const sync = expectSyncErrorSync(ready);
|
expectSyncErrorSync(ready);
|
||||||
|
|
||||||
expect(ready.source).toBe("stale");
|
expect(ready.source).toBe("stale");
|
||||||
expect(ready.agent.id).toBe("agent-1");
|
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", () => {
|
it("returns blocking error before first paint when refresh fails", () => {
|
||||||
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(ready.agent.status).toBe("closed");
|
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", () => {
|
it("clears initial sync failure memory after history is hydrated", () => {
|
||||||
const memory = createBaseMemory({
|
const memory = createBaseMemory({
|
||||||
hasRenderedReady: true,
|
hasRenderedReady: true,
|
||||||
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
const ready = expectReadyState(result.state);
|
const ready = expectReadyState(result.state);
|
||||||
const sync = expectCatchingUpSync(ready);
|
const sync = expectCatchingUpSync(ready);
|
||||||
|
|
||||||
expect(sync.ui).toBe("toast");
|
expect(sync.ui).toBe("silent");
|
||||||
expect(result.memory.hadInitialSyncFailure).toBe(false);
|
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 {
|
export interface AgentScreenMachineMemory {
|
||||||
hasRenderedReady: boolean;
|
hasRenderedReady: boolean;
|
||||||
lastReadyAgent: AgentScreenAgent | null;
|
lastReadyAgent: AgentScreenAgent | null;
|
||||||
activeToastLatch: AgentScreenToastLatch;
|
|
||||||
hadInitialSyncFailure: boolean;
|
hadInitialSyncFailure: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +51,8 @@ export type AgentScreenReadySyncState =
|
|||||||
| {
|
| {
|
||||||
status: "catching_up";
|
status: "catching_up";
|
||||||
ui: "overlay" | "silent";
|
ui: "overlay" | "silent";
|
||||||
shouldEmitHistoryRefreshToast: false;
|
|
||||||
}
|
}
|
||||||
| {
|
| { status: "sync_error" };
|
||||||
status: "catching_up";
|
|
||||||
ui: "toast";
|
|
||||||
shouldEmitHistoryRefreshToast: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
status: "sync_error";
|
|
||||||
shouldEmitSyncErrorToast: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentScreenViewState =
|
export type AgentScreenViewState =
|
||||||
| {
|
| {
|
||||||
@@ -98,7 +86,6 @@ export function deriveAgentScreenViewState({
|
|||||||
const nextMemory: AgentScreenMachineMemory = {
|
const nextMemory: AgentScreenMachineMemory = {
|
||||||
hasRenderedReady: memory.hasRenderedReady,
|
hasRenderedReady: memory.hasRenderedReady,
|
||||||
lastReadyAgent: memory.lastReadyAgent,
|
lastReadyAgent: memory.lastReadyAgent,
|
||||||
activeToastLatch: memory.activeToastLatch,
|
|
||||||
hadInitialSyncFailure: memory.hadInitialSyncFailure,
|
hadInitialSyncFailure: memory.hadInitialSyncFailure,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,45 +167,23 @@ export function deriveAgentScreenViewState({
|
|||||||
|
|
||||||
let sync: AgentScreenReadySyncState;
|
let sync: AgentScreenReadySyncState;
|
||||||
if (!input.isConnected) {
|
if (!input.isConnected) {
|
||||||
nextMemory.activeToastLatch = "none";
|
|
||||||
sync = { status: "reconnecting" };
|
sync = { status: "reconnecting" };
|
||||||
} else if (input.missingAgentState.kind === "error") {
|
} else if (input.missingAgentState.kind === "error") {
|
||||||
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
|
sync = { status: "sync_error" };
|
||||||
nextMemory.activeToastLatch = "sync_error";
|
|
||||||
sync = {
|
|
||||||
status: "sync_error",
|
|
||||||
shouldEmitSyncErrorToast,
|
|
||||||
};
|
|
||||||
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
||||||
let ui: "overlay" | "toast" | "silent";
|
let ui: "overlay" | "silent";
|
||||||
if (input.shouldUseOptimisticStream) {
|
if (input.shouldUseOptimisticStream) {
|
||||||
ui = "silent";
|
ui = "silent";
|
||||||
} else if (input.hasHydratedHistoryBefore) {
|
} else if (input.hasHydratedHistoryBefore) {
|
||||||
ui = "toast";
|
ui = "silent";
|
||||||
} else if (nextMemory.hadInitialSyncFailure) {
|
} else if (nextMemory.hadInitialSyncFailure) {
|
||||||
ui = "silent";
|
ui = "silent";
|
||||||
} else {
|
} else {
|
||||||
ui = "overlay";
|
ui = "overlay";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ui === "toast") {
|
sync = { status: "catching_up", ui };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
nextMemory.activeToastLatch = "none";
|
|
||||||
sync = { status: "idle" };
|
sync = { status: "idle" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +210,6 @@ export function useAgentScreenStateMachine({
|
|||||||
const memoryRef = useRef<AgentScreenMachineMemory>({
|
const memoryRef = useRef<AgentScreenMachineMemory>({
|
||||||
hasRenderedReady: false,
|
hasRenderedReady: false,
|
||||||
lastReadyAgent: null,
|
lastReadyAgent: null,
|
||||||
activeToastLatch: "none",
|
|
||||||
hadInitialSyncFailure: false,
|
hadInitialSyncFailure: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,7 +218,6 @@ export function useAgentScreenStateMachine({
|
|||||||
memoryRef.current = {
|
memoryRef.current = {
|
||||||
hasRenderedReady: false,
|
hasRenderedReady: false,
|
||||||
lastReadyAgent: null,
|
lastReadyAgent: null,
|
||||||
activeToastLatch: "none",
|
|
||||||
hadInitialSyncFailure: false,
|
hadInitialSyncFailure: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
cwd: string,
|
||||||
mode: "uncommitted" | "base",
|
mode: "uncommitted" | "base",
|
||||||
baseRef?: string,
|
baseRef?: string,
|
||||||
|
ignoreWhitespace?: boolean,
|
||||||
) {
|
) {
|
||||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseCheckoutDiffQueryOptions {
|
interface UseCheckoutDiffQueryOptions {
|
||||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
|||||||
function normalizeCheckoutDiffCompare(compare: {
|
function normalizeCheckoutDiffCompare(compare: {
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
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") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
const trimmedBaseRef = compare.baseRef?.trim();
|
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({
|
export function useCheckoutDiffQuery({
|
||||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
mode,
|
mode,
|
||||||
baseRef,
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutDiffQueryOptions) {
|
}: UseCheckoutDiffQueryOptions) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
const hookInstanceId = useId();
|
const hookInstanceId = useId();
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
[mode, baseRef],
|
[mode, baseRef, ignoreWhitespace],
|
||||||
);
|
);
|
||||||
const compareMode = normalizedCompare.mode;
|
const compareMode = normalizedCompare.mode;
|
||||||
const compareBaseRef = normalizedCompare.baseRef;
|
const compareBaseRef = normalizedCompare.baseRef;
|
||||||
|
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||||
[serverId, cwd, mode, baseRef],
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
|||||||
const payload = await client.getCheckoutDiff(cwd, {
|
const payload = await client.getCheckoutDiff(cwd, {
|
||||||
mode: compareMode,
|
mode: compareMode,
|
||||||
baseRef: compareBaseRef,
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
compareMode,
|
compareMode,
|
||||||
compareBaseRef ?? "",
|
compareBaseRef ?? "",
|
||||||
|
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||||
].join(":");
|
].join(":");
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
|||||||
{
|
{
|
||||||
mode: compareMode,
|
mode: compareMode,
|
||||||
baseRef: compareBaseRef,
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
},
|
},
|
||||||
{ subscriptionId },
|
{ subscriptionId },
|
||||||
)
|
)
|
||||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
|||||||
serverId,
|
serverId,
|
||||||
compareMode,
|
compareMode,
|
||||||
compareBaseRef,
|
compareBaseRef,
|
||||||
|
compareIgnoreWhitespace,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryClient,
|
queryClient,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function useCommandCenter() {
|
|||||||
|
|
||||||
const settingsRoute = useMemo<Href>(() => {
|
const settingsRoute = useMemo<Href>(() => {
|
||||||
const serverIdFromPath = activeServerId;
|
const serverIdFromPath = activeServerId;
|
||||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
||||||
}, [activeServerId]);
|
}, [activeServerId]);
|
||||||
|
|
||||||
const actionItems = useMemo(() => {
|
const actionItems = useMemo(() => {
|
||||||
@@ -220,7 +220,7 @@ export function useCommandCenter() {
|
|||||||
workspaceId: agent.cwd,
|
workspaceId: agent.cwd,
|
||||||
target: { kind: "agent", agentId: agent.id },
|
target: { kind: "agent", agentId: agent.id },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
},
|
},
|
||||||
[setOpen],
|
[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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -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 { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
||||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
const {
|
||||||
useExplorerSidebarAnimation();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useExplorerSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
onOpen();
|
||||||
|
}, [onOpen, gestureAnimatingRef]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(enabled)
|
.enabled(enabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(onOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
onOpen,
|
openGestureRef,
|
||||||
|
handleGestureOpen,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
touchStartY,
|
touchStartY,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { mergeProviderPreferences } from "./use-form-preferences";
|
import {
|
||||||
|
buildFavoriteModelKey,
|
||||||
|
isFavoriteModel,
|
||||||
|
mergeProviderPreferences,
|
||||||
|
toggleFavoriteModel,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
describe("mergeProviderPreferences", () => {
|
describe("mergeProviderPreferences", () => {
|
||||||
it("stores the selected model for a provider", () => {
|
it("stores the selected model for a provider", () => {
|
||||||
@@ -55,3 +60,92 @@ describe("mergeProviderPreferences", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("favorite model preferences", () => {
|
||||||
|
it("builds a stable favorite key from provider and model", () => {
|
||||||
|
expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe(
|
||||||
|
"claude:sonnet-4.6",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a model to favorites without dropping other preferences", () => {
|
||||||
|
expect(
|
||||||
|
toggleFavoriteModel({
|
||||||
|
preferences: {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoriteModels: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a model from favorites when toggled again", () => {
|
||||||
|
expect(
|
||||||
|
toggleFavoriteModel({
|
||||||
|
preferences: {
|
||||||
|
favoriteModels: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
favoriteModels: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports whether a model is favorited", () => {
|
||||||
|
expect(
|
||||||
|
isFavoriteModel({
|
||||||
|
preferences: {
|
||||||
|
favoriteModels: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isFavoriteModel({
|
||||||
|
preferences: {
|
||||||
|
favoriteModels: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provider: "claude",
|
||||||
|
modelId: "sonnet-4.6",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|||||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||||
|
|
||||||
|
export interface FavoriteModelPreference {
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteModelRow {
|
||||||
|
favoriteKey: string;
|
||||||
|
provider: string;
|
||||||
|
providerLabel: string;
|
||||||
|
modelId: string;
|
||||||
|
modelLabel: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const providerPreferencesSchema = z.object({
|
const providerPreferencesSchema = z.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
@@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({
|
|||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||||
|
favoriteModels: z.array(
|
||||||
|
z.object({
|
||||||
|
provider: z.string(),
|
||||||
|
modelId: z.string(),
|
||||||
|
}),
|
||||||
|
).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
||||||
@@ -66,6 +86,41 @@ export function mergeProviderPreferences(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
|
||||||
|
return `${input.provider}:${input.modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFavoriteModel(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
}): boolean {
|
||||||
|
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
|
||||||
|
return (args.preferences.favoriteModels ?? []).some(
|
||||||
|
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFavoriteModel(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
}): FormPreferences {
|
||||||
|
const favorite = { provider: args.provider, modelId: args.modelId };
|
||||||
|
const favoriteKey = buildFavoriteModelKey(favorite);
|
||||||
|
const existingFavorites = args.preferences.favoriteModels ?? [];
|
||||||
|
const hasFavorite = existingFavorites.some(
|
||||||
|
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...args.preferences,
|
||||||
|
favoriteModels: hasFavorite
|
||||||
|
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
|
||||||
|
: [...existingFavorites, favorite],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isPending } = useQuery({
|
const { data, isPending } = useQuery({
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export function useKeyboardShortcuts({
|
|||||||
id: "message-input.focus",
|
id: "message-input.focus",
|
||||||
scope: "message-input",
|
scope: "message-input",
|
||||||
});
|
});
|
||||||
|
case "send":
|
||||||
|
return keyboardActionDispatcher.dispatch({
|
||||||
|
id: "message-input.send",
|
||||||
|
scope: "message-input",
|
||||||
|
});
|
||||||
case "dictation-toggle":
|
case "dictation-toggle":
|
||||||
return keyboardActionDispatcher.dispatch({
|
return keyboardActionDispatcher.dispatch({
|
||||||
id: "message-input.dictation-toggle",
|
id: "message-input.dictation-toggle",
|
||||||
@@ -139,6 +144,11 @@ export function useKeyboardShortcuts({
|
|||||||
id: "message-input.dictation-cancel",
|
id: "message-input.dictation-cancel",
|
||||||
scope: "message-input",
|
scope: "message-input",
|
||||||
});
|
});
|
||||||
|
case "dictation-confirm":
|
||||||
|
return keyboardActionDispatcher.dispatch({
|
||||||
|
id: "message-input.dictation-confirm",
|
||||||
|
scope: "message-input",
|
||||||
|
});
|
||||||
case "voice-toggle":
|
case "voice-toggle":
|
||||||
return keyboardActionDispatcher.dispatch({
|
return keyboardActionDispatcher.dispatch({
|
||||||
id: "message-input.voice-toggle",
|
id: "message-input.voice-toggle",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { 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 { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
@@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const next = new Map<string, WorkspaceDescriptor>();
|
const next = new Map<string, WorkspaceDescriptor>();
|
||||||
|
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
});
|
});
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = toWorkspaceDescriptor(entry);
|
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) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|||||||
*/
|
*/
|
||||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
router.navigate(href as any);
|
router.navigate(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceNavigation() {
|
export function useWorkspaceNavigation() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind =
|
|||||||
| "queue"
|
| "queue"
|
||||||
| "dictation-toggle"
|
| "dictation-toggle"
|
||||||
| "dictation-cancel"
|
| "dictation-cancel"
|
||||||
|
| "dictation-confirm"
|
||||||
| "voice-toggle"
|
| "voice-toggle"
|
||||||
| "voice-mute-toggle";
|
| "voice-mute-toggle";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
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: {
|
export function resolveKeyboardFocusScope(input: {
|
||||||
target: EventTarget | null;
|
target: EventTarget | null;
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
}): KeyboardFocusScope {
|
}): KeyboardFocusScope {
|
||||||
const { target, commandCenterOpen } = input;
|
const { target, commandCenterOpen } = input;
|
||||||
if (!(target instanceof Element)) {
|
const candidates = getFocusCandidateElements(target);
|
||||||
|
if (candidates.length === 0) {
|
||||||
return commandCenterOpen ? "command-center" : "other";
|
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";
|
return "terminal";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
commandCenterOpen &&
|
commandCenterOpen &&
|
||||||
(target.closest("[data-testid='command-center-panel']") ||
|
candidates.some((element) =>
|
||||||
target.closest("[data-testid='command-center-input']"))
|
Boolean(
|
||||||
|
element.closest("[data-testid='command-center-panel']") ||
|
||||||
|
element.closest("[data-testid='command-center-input']"),
|
||||||
|
),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return "command-center";
|
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";
|
return "message-input";
|
||||||
}
|
}
|
||||||
|
|
||||||
const editable = target as HTMLElement;
|
if (
|
||||||
if (editable.isContentEditable) {
|
candidates.some((element) => {
|
||||||
return commandCenterOpen ? "command-center" : "editable";
|
const editable = element as HTMLElement;
|
||||||
}
|
if (editable.isContentEditable) {
|
||||||
|
return true;
|
||||||
const tag = target.tagName.toLowerCase();
|
}
|
||||||
if (tag === "input" || tag === "textarea" || tag === "select") {
|
const tag = element.tagName.toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select";
|
||||||
|
})
|
||||||
|
) {
|
||||||
return commandCenterOpen ? "command-center" : "editable";
|
return commandCenterOpen ? "command-center" : "editable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work
|
|||||||
|
|
||||||
export type KeyboardActionId =
|
export type KeyboardActionId =
|
||||||
| "message-input.focus"
|
| "message-input.focus"
|
||||||
|
| "message-input.send"
|
||||||
| "message-input.dictation-toggle"
|
| "message-input.dictation-toggle"
|
||||||
| "message-input.dictation-cancel"
|
| "message-input.dictation-cancel"
|
||||||
|
| "message-input.dictation-confirm"
|
||||||
| "message-input.voice-toggle"
|
| "message-input.voice-toggle"
|
||||||
| "message-input.voice-mute-toggle"
|
| "message-input.voice-mute-toggle"
|
||||||
| "workspace.tab.new"
|
| "workspace.tab.new"
|
||||||
@@ -27,8 +29,10 @@ export type KeyboardActionId =
|
|||||||
|
|
||||||
export type KeyboardActionDefinition =
|
export type KeyboardActionDefinition =
|
||||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||||
|
| { id: "message-input.send"; scope: KeyboardActionScope }
|
||||||
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
||||||
| { id: "message-input.dictation-cancel"; 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-toggle"; scope: KeyboardActionScope }
|
||||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.new"; 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",
|
id: "message-input-voice-mute-toggle",
|
||||||
action: "message-input.action",
|
action: "message-input.action",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user