mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
137
Commits
v0.1.44
...
add-webstorm
| 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 |
@@ -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:
|
||||||
@@ -219,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
|
||||||
@@ -285,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
|
||||||
|
|||||||
+101
@@ -1,5 +1,106 @@
|
|||||||
# 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
|
## 0.1.44 - 2026-04-03
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Mobile Testing
|
||||||
|
|
||||||
|
## Maestro
|
||||||
|
|
||||||
|
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||||
|
|
||||||
|
Run a flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
maestro test packages/app/maestro/my-flow.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||||
|
mkdir -p /tmp/maestro-out
|
||||||
|
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||||
|
|
||||||
|
### Element targeting
|
||||||
|
|
||||||
|
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component
|
||||||
|
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Flow
|
||||||
|
- tapOn:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional steps
|
||||||
|
|
||||||
|
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||||
|
|
||||||
|
### Don't use launchApp against a running dev app
|
||||||
|
|
||||||
|
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||||
|
|
||||||
|
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||||
|
|
||||||
|
### Swipe gestures
|
||||||
|
|
||||||
|
Use `start`/`end` with percentage coordinates for precise control:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Edge swipe from left to open sidebar
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||||
|
|
||||||
|
For async elements, use `extendedWaitUntil`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Online.*"
|
||||||
|
timeout: 90000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev client handling
|
||||||
|
|
||||||
|
Two reusable flows handle Expo dev client screens after launch:
|
||||||
|
|
||||||
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
## Self-verification loops
|
||||||
|
|
||||||
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|
||||||
|
This pattern also lets agents self-verify fixes without manual user testing.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. Run baseline Maestro flow (confirm feature works)
|
||||||
|
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||||
|
3. Re-run Maestro flow (confirm feature still works)
|
||||||
|
4. Repeat N iterations to catch intermittent failures
|
||||||
|
|
||||||
|
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||||
|
|
||||||
|
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||||
|
```
|
||||||
|
|
||||||
|
Key elements of the script pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
# Toggle system state
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
|
||||||
|
# Wait for change to propagate
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run Maestro flow and capture result
|
||||||
|
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo "PASS"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
|
### The crash
|
||||||
|
|
||||||
|
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||||
|
|
||||||
|
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BAD: Unistyles dynamic style on Animated.View
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// GOOD: static stylesheet + inline theme values
|
||||||
|
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const staticStyles = RNStyleSheet.create({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||||
|
|
||||||
|
## iOS Simulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Screenshot
|
||||||
|
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||||
|
|
||||||
|
# Dark/light mode
|
||||||
|
xcrun simctl ui booted appearance # check current
|
||||||
|
xcrun simctl ui booted appearance dark # set dark
|
||||||
|
xcrun simctl ui booted appearance light # set light
|
||||||
|
```
|
||||||
|
|
||||||
|
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||||
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
|||||||
|
|
||||||
## Standard release (patch)
|
## 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-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q=";
|
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
+57
-147
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.44",
|
"version": "0.1.51-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.44",
|
"version": "0.1.51-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -15932,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",
|
||||||
@@ -18545,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",
|
||||||
@@ -21859,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",
|
||||||
@@ -22582,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",
|
||||||
@@ -23319,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"
|
||||||
@@ -26558,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"
|
||||||
@@ -27464,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",
|
||||||
@@ -27701,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"
|
||||||
@@ -28264,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",
|
||||||
@@ -31090,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",
|
||||||
@@ -32066,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",
|
||||||
@@ -34996,16 +34906,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.44",
|
"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.44",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.44",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.44",
|
"@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",
|
||||||
@@ -35122,11 +35032,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.44",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.44",
|
"@getpaseo/relay": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.44",
|
"@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",
|
||||||
@@ -35167,11 +35077,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.44",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.44",
|
"@getpaseo/cli": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.44",
|
"@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"
|
||||||
@@ -35205,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.44",
|
"version": "0.1.51-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35406,7 +35316,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.44",
|
"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",
|
||||||
@@ -35432,7 +35342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.44",
|
"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",
|
||||||
@@ -35448,14 +35358,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.44",
|
"version": "0.1.51-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@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.44",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/relay": "0.1.44",
|
"@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",
|
||||||
@@ -35471,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",
|
||||||
@@ -35854,7 +35764,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.44",
|
"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.44",
|
"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.44",
|
"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.44",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.44",
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
"@getpaseo/server": "0.1.44",
|
"@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,11 +3,20 @@ 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 { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
buildFavoriteModelKey,
|
buildFavoriteModelKey,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -24,6 +33,7 @@ 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,
|
||||||
@@ -36,17 +46,18 @@ import {
|
|||||||
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(
|
const PROVIDER_DEFINITION_MAP = new Map(
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||||
@@ -63,6 +74,7 @@ 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;
|
||||||
@@ -73,6 +85,9 @@ type ControlledAgentStatusBarProps = {
|
|||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
canSelectModelProvider?: (providerId: string) => boolean;
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
|
features?: AgentFeature[];
|
||||||
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -92,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(
|
||||||
@@ -112,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,
|
||||||
@@ -152,6 +203,7 @@ function ControlledStatusBar({
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
|
onSelectProviderAndModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
@@ -162,6 +214,9 @@ function ControlledStatusBar({
|
|||||||
canSelectModelProvider,
|
canSelectModelProvider,
|
||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavoriteModel,
|
onToggleFavoriteModel,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -203,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;
|
||||||
|
|
||||||
@@ -280,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(
|
||||||
@@ -352,6 +411,7 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -455,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;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -490,15 +649,20 @@ function ControlledStatusBar({
|
|||||||
selectedModel={selectedModelId ?? ""}
|
selectedModel={selectedModelId ?? ""}
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
onSelect={(selectedProviderId, modelId) => {
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
if (selectedProviderId !== provider) {
|
if (onSelectProviderAndModel) {
|
||||||
onSelectProvider?.(selectedProviderId);
|
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||||
|
} else {
|
||||||
|
if (selectedProviderId !== provider) {
|
||||||
|
onSelectProvider?.(selectedProviderId);
|
||||||
|
}
|
||||||
|
onSelectModel?.(modelId);
|
||||||
}
|
}
|
||||||
onSelectModel?.(modelId);
|
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onClose={onDropdownClose}
|
||||||
renderTrigger={({ selectedModelLabel }) => (
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -508,6 +672,7 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -533,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>
|
||||||
@@ -593,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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -602,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) => {
|
||||||
@@ -614,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;
|
||||||
}),
|
}),
|
||||||
@@ -626,52 +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 snapshotModels = useMemo(() => {
|
||||||
}
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
return null;
|
||||||
if (payload.error) {
|
}
|
||||||
throw new Error(payload.error);
|
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||||
}
|
return entry?.models ?? null;
|
||||||
return payload.models ?? [];
|
}, [snapshotEntries, agent?.provider]);
|
||||||
},
|
|
||||||
});
|
const models = snapshotModels;
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||||
return definition ? [definition] : [];
|
return definition ? [definition] : [];
|
||||||
}, [agent?.provider]);
|
}, [agent?.provider]);
|
||||||
|
|
||||||
const agentProviderModelQuery = useQuery({
|
|
||||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
|
||||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client || !agent) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return payload.models ?? [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && agentProviderModelQuery.data) {
|
if (agent?.provider && snapshotModels) {
|
||||||
map.set(agent.provider, agentProviderModelQuery.data);
|
map.set(agent.provider, snapshotModels);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
}, [agent?.provider, snapshotModels]);
|
||||||
|
|
||||||
const models = modelsQuery.data ?? null;
|
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -782,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -805,6 +1042,9 @@ 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";
|
||||||
@@ -849,6 +1089,7 @@ export function DraftAgentStatusBar({
|
|||||||
}}
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
@@ -858,6 +1099,9 @@ 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>
|
||||||
@@ -870,28 +1114,33 @@ export function DraftAgentStatusBar({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledStatusBar
|
<>
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
allProviderModels={allProviderModels}
|
providerDefinitions={providerDefinitions}
|
||||||
modeOptions={mappedModeOptions}
|
allProviderModels={allProviderModels}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
modelOptions={modelOptions}
|
onSelectMode={onSelectMode}
|
||||||
selectedModelId={selectedModel}
|
modelOptions={modelOptions}
|
||||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
selectedModelId={selectedModel}
|
||||||
isModelLoading={isAllModelsLoading}
|
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||||
favoriteKeys={favoriteKeys}
|
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
isModelLoading={isAllModelsLoading}
|
||||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
favoriteKeys={favoriteKeys}
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
});
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
}}
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
});
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
}}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
disabled={disabled}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -15,10 +17,14 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
const IS_WEB = Platform.OS === "web";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
|
||||||
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +35,8 @@ import {
|
|||||||
type SelectorModelRow,
|
type SelectorModelRow,
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
|
// TODO: this should be configured per provider in the provider manifest
|
||||||
|
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
@@ -51,6 +58,7 @@ interface CombinedModelSelectorProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +75,6 @@ interface SelectorContentProps {
|
|||||||
canSelectProvider: (provider: string) => boolean;
|
canSelectProvider: (provider: string) => boolean;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onBack?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
@@ -99,6 +106,22 @@ function partitionRows(
|
|||||||
return { favoriteRows, regularRows };
|
return { favoriteRows, regularRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortFavoritesFirst(
|
||||||
|
rows: SelectorModelRow[],
|
||||||
|
favoriteKeys: Set<string>,
|
||||||
|
): SelectorModelRow[] {
|
||||||
|
const favorites: SelectorModelRow[] = [];
|
||||||
|
const rest: SelectorModelRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (favoriteKeys.has(row.favoriteKey)) {
|
||||||
|
favorites.push(row);
|
||||||
|
} else {
|
||||||
|
rest.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...favorites, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
function groupRowsByProvider(
|
function groupRowsByProvider(
|
||||||
rows: SelectorModelRow[],
|
rows: SelectorModelRow[],
|
||||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||||
@@ -126,6 +149,7 @@ function ModelRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
elevated = false,
|
||||||
onPress,
|
onPress,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
@@ -133,12 +157,12 @@ function ModelRow({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
elevated?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProviderIcon = getProviderIcon(row.provider);
|
const ProviderIcon = getProviderIcon(row.provider);
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
@@ -148,13 +172,18 @@ function ModelRow({
|
|||||||
[onToggleFavorite, row.modelId, row.provider],
|
[onToggleFavorite, row.modelId, row.provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const item = (
|
const showDescription =
|
||||||
|
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
label={row.modelLabel}
|
label={row.modelLabel}
|
||||||
|
description={showDescription ? row.description : undefined}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
elevated={elevated}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
|
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||||
trailingSlot={
|
trailingSlot={
|
||||||
onToggleFavorite && !disabled ? (
|
onToggleFavorite && !disabled ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -187,21 +216,6 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isWeb || !row.description) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
|
||||||
<View>{item}</View>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" align="center" offset={4}>
|
|
||||||
<Text style={styles.tooltipText}>{row.description}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FavoritesSection({
|
function FavoritesSection({
|
||||||
@@ -228,7 +242,7 @@ function FavoritesSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={styles.favoritesContainer}>
|
||||||
<View style={styles.sectionHeading}>
|
<View style={styles.sectionHeading}>
|
||||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -239,11 +253,11 @@ function FavoritesSection({
|
|||||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||||
disabled={!canSelectProvider(row.provider)}
|
disabled={!canSelectProvider(row.provider)}
|
||||||
|
elevated
|
||||||
onPress={() => onSelect(row.provider, row.modelId)}
|
onPress={() => onSelect(row.provider, row.modelId)}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<View style={styles.separator} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,6 +272,7 @@ function GroupedProviderRows({
|
|||||||
canSelectProvider,
|
canSelectProvider,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
viewKind,
|
||||||
}: {
|
}: {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||||
@@ -268,6 +283,7 @@ function GroupedProviderRows({
|
|||||||
canSelectProvider: (provider: string) => boolean;
|
canSelectProvider: (provider: string) => boolean;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
viewKind: SelectorView["kind"];
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
@@ -276,19 +292,14 @@ function GroupedProviderRows({
|
|||||||
{groupedRows.map((group, index) => {
|
{groupedRows.map((group, index) => {
|
||||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||||
const ProvIcon = getProviderIcon(group.providerId);
|
const ProvIcon = getProviderIcon(group.providerId);
|
||||||
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
|
const isInline = viewKind === "provider";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={group.providerId}>
|
<View key={group.providerId}>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
{isInline ? (
|
{isInline ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.sectionHeading}>
|
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
||||||
<Text style={styles.sectionHeadingText}>
|
|
||||||
{providerDefinition?.label ?? group.providerLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{group.rows.map((row) => (
|
|
||||||
<ModelRow
|
<ModelRow
|
||||||
key={row.favoriteKey}
|
key={row.favoriteKey}
|
||||||
row={row}
|
row={row}
|
||||||
@@ -309,11 +320,13 @@ function GroupedProviderRows({
|
|||||||
pressed && styles.drillDownRowPressed,
|
pressed && styles.drillDownRowPressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
|
<Text style={styles.drillDownCount}>
|
||||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -324,6 +337,46 @@ function GroupedProviderRows({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderSearchInput({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
autoFocus = false,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.providerSearchContainer}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<InputComponent
|
||||||
|
ref={inputRef as any}
|
||||||
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
|
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
||||||
|
placeholder="Search models..."
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SelectorContent({
|
function SelectorContent({
|
||||||
view,
|
view,
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
@@ -337,8 +390,8 @@ function SelectorContent({
|
|||||||
canSelectProvider,
|
canSelectProvider,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
onBack,
|
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||||
[allProviderModels, providerDefinitions],
|
[allProviderModels, providerDefinitions],
|
||||||
@@ -363,35 +416,41 @@ function SelectorContent({
|
|||||||
[favoriteKeys, visibleRows],
|
[favoriteKeys, visibleRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
|
// Group ALL visible rows by provider — favorites are a cross-cutting view,
|
||||||
|
// not a partition. A model being favorited doesn't remove it from its provider.
|
||||||
|
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
||||||
|
|
||||||
|
// When searching at Level 1, filter grouped rows to only providers whose name or models match
|
||||||
|
const filteredGroupedRows = useMemo(() => {
|
||||||
|
if (view.kind === "provider" || !normalizedQuery) {
|
||||||
|
return allGroupedRows;
|
||||||
|
}
|
||||||
|
return allGroupedRows.filter(
|
||||||
|
(group) =>
|
||||||
|
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
|
||||||
|
);
|
||||||
|
}, [allGroupedRows, normalizedQuery, view.kind]);
|
||||||
|
|
||||||
|
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{view.kind === "provider" ? (
|
{view.kind === "all" ? (
|
||||||
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
|
<FavoritesSection
|
||||||
|
favoriteRows={favoriteRows}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onSelect={onSelect}
|
||||||
|
canSelectProvider={canSelectProvider}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SearchInput
|
{filteredGroupedRows.length > 0 ? (
|
||||||
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
|
|
||||||
value={searchQuery}
|
|
||||||
onChangeText={onSearchChange}
|
|
||||||
autoFocus={Platform.OS === "web"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FavoritesSection
|
|
||||||
favoriteRows={favoriteRows}
|
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onSelect={onSelect}
|
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{groupedRegularRows.length > 0 ? (
|
|
||||||
<GroupedProviderRows
|
<GroupedProviderRows
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
groupedRows={groupedRegularRows}
|
groupedRows={filteredGroupedRows}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -399,12 +458,13 @@ function SelectorContent({
|
|||||||
canSelectProvider={canSelectProvider}
|
canSelectProvider={canSelectProvider}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
|
viewKind={view.kind}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
|
{!hasResults ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Search size={16} color="#777" />
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -437,8 +497,8 @@ function ProviderBackButton({
|
|||||||
pressed && styles.backButtonPressed,
|
pressed && styles.backButtonPressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -455,6 +515,7 @@ export function CombinedModelSelector({
|
|||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -465,25 +526,35 @@ export function CombinedModelSelector({
|
|||||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
||||||
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||||
|
const providers = Array.from(allProviderModels.keys());
|
||||||
|
if (providers.length !== 1) return null;
|
||||||
|
const providerId = providers[0]!;
|
||||||
|
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||||
|
return { kind: "provider", providerId, providerLabel: label };
|
||||||
|
}, [allProviderModels, providerDefinitions]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView({ kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onClose, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView({ kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect],
|
[onSelect, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
@@ -501,6 +572,15 @@ export function CombinedModelSelector({
|
|||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
|
const desktopFixedHeight = useMemo(() => {
|
||||||
|
if (view.kind !== "provider") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const models = allProviderModels.get(view.providerId);
|
||||||
|
const modelCount = models?.length ?? 0;
|
||||||
|
return Math.min(80 + modelCount * 40, 400);
|
||||||
|
}, [allProviderModels, view]);
|
||||||
|
|
||||||
const triggerLabel = useMemo(() => {
|
const triggerLabel = useMemo(() => {
|
||||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||||
return selectedModelLabel;
|
return selectedModelLabel;
|
||||||
@@ -568,7 +648,30 @@ export function CombinedModelSelector({
|
|||||||
stackBehavior="push"
|
stackBehavior="push"
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
title="Select model"
|
title="Select model"
|
||||||
|
stickyHeader={
|
||||||
|
view.kind === "provider" ? (
|
||||||
|
<View style={styles.level2Header}>
|
||||||
|
{!singleProviderView ? (
|
||||||
|
<ProviderBackButton
|
||||||
|
providerId={view.providerId}
|
||||||
|
providerLabel={view.providerLabel}
|
||||||
|
onBack={() => {
|
||||||
|
setView({ kind: "all" });
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ProviderSearchInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoFocus={Platform.OS === "web"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isContentReady ? (
|
{isContentReady ? (
|
||||||
<SelectorContent
|
<SelectorContent
|
||||||
@@ -586,13 +689,6 @@ export function CombinedModelSelector({
|
|||||||
onDrillDown={(providerId, providerLabel) => {
|
onDrillDown={(providerId, providerLabel) => {
|
||||||
setView({ kind: "provider", providerId, providerLabel });
|
setView({ kind: "provider", providerId, providerLabel });
|
||||||
}}
|
}}
|
||||||
onBack={
|
|
||||||
view.kind === "provider"
|
|
||||||
? () => {
|
|
||||||
setView({ kind: "all" });
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
@@ -634,17 +730,23 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
},
|
},
|
||||||
|
favoritesContainer: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
separator: {
|
separator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
marginVertical: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
sectionHeadingText: {
|
sectionHeadingText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -658,6 +760,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
drillDownRowHovered: {
|
drillDownRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
@@ -667,8 +770,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
drillDownText: {
|
drillDownText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
drillDownTrailing: {
|
drillDownTrailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -679,6 +782,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
level2Header: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -687,9 +795,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
backButtonHovered: {
|
backButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
backButtonPressed: {
|
backButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -720,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
favoriteButtonPressed: {
|
favoriteButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
tooltipText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
sheetLoadingState: {
|
sheetLoadingState: {
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -734,4 +839,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
providerSearchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
|
},
|
||||||
|
providerSearchInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import Svg, { Circle } from "react-native-svg";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
type ContextWindowMeterProps = {
|
||||||
|
maxTokens: number;
|
||||||
|
usedTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SVG_SIZE = 20;
|
||||||
|
const CENTER = SVG_SIZE / 2;
|
||||||
|
const RADIUS = 7;
|
||||||
|
const STROKE_WIDTH = 2.25;
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
|
||||||
|
function isValidMaxTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsedTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||||
|
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (usedTokens / maxTokens) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercentage(value: number): number {
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${Math.round(value / 1_000_000)}m`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${Math.round(value / 1_000)}k`;
|
||||||
|
}
|
||||||
|
return Math.round(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterColors(
|
||||||
|
percentage: number,
|
||||||
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
|
): { progress: string; track: string } {
|
||||||
|
const track = theme.colors.surface3;
|
||||||
|
if (percentage > 90) {
|
||||||
|
return { progress: theme.colors.destructive, track };
|
||||||
|
}
|
||||||
|
if (percentage >= 70) {
|
||||||
|
return { progress: theme.colors.palette.amber[500], track };
|
||||||
|
}
|
||||||
|
return { progress: theme.colors.foregroundMuted, track };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||||
|
|
||||||
|
if (percentage === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedPercentage = clampPercentage(percentage);
|
||||||
|
const roundedPercentage = Math.round(percentage);
|
||||||
|
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||||
|
const colors = getMeterColors(clampedPercentage, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||||
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
|
<Pressable
|
||||||
|
style={styles.container}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||||
|
>
|
||||||
|
<Svg
|
||||||
|
width={SVG_SIZE}
|
||||||
|
height={SVG_SIZE}
|
||||||
|
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||||
|
style={styles.svg}
|
||||||
|
accessibilityElementsHidden
|
||||||
|
importantForAccessibility="no-hide-descendants"
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.track}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.progress}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<View style={styles.tooltipContent}>
|
||||||
|
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||||
|
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.tooltipDetail}
|
||||||
|
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
svg: {
|
||||||
|
transform: [{ rotate: "-90deg" }],
|
||||||
|
},
|
||||||
|
tooltipContent: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tooltipTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
tooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
tooltipDetail: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: theme.fontSize.xs * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { 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,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
@@ -129,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;
|
||||||
@@ -144,7 +153,6 @@ 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 scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
setExpandedPaths(new Set(["."]));
|
}, [workspaceStateKey]);
|
||||||
}, [actionsWorkspaceStateKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -169,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,
|
||||||
@@ -193,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(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 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,
|
||||||
|
|||||||
@@ -79,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;
|
||||||
@@ -100,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.
|
||||||
@@ -201,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,
|
||||||
@@ -242,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") {
|
||||||
@@ -271,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;
|
||||||
@@ -1003,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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -3,13 +3,14 @@ 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 { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||||
import { OpenCodeIcon } from "@/components/icons/opencode-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,
|
||||||
"claude-acp": 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,
|
copilot: CopilotIcon as unknown as typeof Bot,
|
||||||
opencode: OpenCodeIcon 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,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";
|
||||||
@@ -53,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,
|
||||||
@@ -105,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
|
|||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
|
||||||
open: "Open",
|
switch (state) {
|
||||||
merged: "Merged",
|
case "merged":
|
||||||
closed: "Closed",
|
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[];
|
||||||
@@ -145,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,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();
|
||||||
@@ -200,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -704,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);
|
||||||
@@ -792,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>
|
||||||
@@ -1068,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]);
|
||||||
|
|
||||||
@@ -1209,160 +1254,6 @@ function WorkspaceRowWithMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NonGitProjectRowWithMenuContent({
|
|
||||||
project,
|
|
||||||
displayName,
|
|
||||||
iconDataUri,
|
|
||||||
workspace,
|
|
||||||
selected,
|
|
||||||
onPress,
|
|
||||||
shortcutNumber,
|
|
||||||
showShortcutBadge,
|
|
||||||
drag,
|
|
||||||
isDragging,
|
|
||||||
dragHandleProps,
|
|
||||||
}: {
|
|
||||||
project: SidebarProjectEntry;
|
|
||||||
displayName: string;
|
|
||||||
iconDataUri: string | null;
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
drag: () => void;
|
|
||||||
isDragging: boolean;
|
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const contextMenu = useContextMenu();
|
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const sessionWorkspaces = useSessionStore(
|
|
||||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
|
||||||
);
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
|
||||||
if (
|
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces: sessionWorkspaces.values(),
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
|
||||||
if (isArchivingWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const confirmed = await confirmDialog({
|
|
||||||
title: "Hide workspace?",
|
|
||||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
|
||||||
confirmLabel: "Hide",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
destructive: true,
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
|
||||||
if (!client) {
|
|
||||||
toast.error("Host is not connected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
|
||||||
try {
|
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
redirectAfterArchive();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
|
||||||
} finally {
|
|
||||||
setIsArchivingWorkspace(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [
|
|
||||||
isArchivingWorkspace,
|
|
||||||
redirectAfterArchive,
|
|
||||||
toast,
|
|
||||||
workspace.name,
|
|
||||||
workspace.serverId,
|
|
||||||
workspace.workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ProjectHeaderRow
|
|
||||||
project={project}
|
|
||||||
displayName={displayName}
|
|
||||||
iconDataUri={iconDataUri}
|
|
||||||
workspace={workspace}
|
|
||||||
selected={selected}
|
|
||||||
chevron={null}
|
|
||||||
onPress={onPress}
|
|
||||||
serverId={null}
|
|
||||||
canCreateWorktree={false}
|
|
||||||
shortcutNumber={shortcutNumber}
|
|
||||||
showShortcutBadge={showShortcutBadge}
|
|
||||||
drag={drag}
|
|
||||||
isDragging={isDragging}
|
|
||||||
isArchiving={isArchivingWorkspace}
|
|
||||||
menuController={contextMenu}
|
|
||||||
dragHandleProps={dragHandleProps}
|
|
||||||
/>
|
|
||||||
<ContextMenuContent
|
|
||||||
align="start"
|
|
||||||
width={220}
|
|
||||||
mobileMode="sheet"
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
|
||||||
>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
|
||||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
|
||||||
pendingLabel="Hiding..."
|
|
||||||
destructive
|
|
||||||
onSelect={handleArchiveWorkspace}
|
|
||||||
>
|
|
||||||
Hide from sidebar
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NonGitProjectRowWithMenu(props: {
|
|
||||||
project: SidebarProjectEntry;
|
|
||||||
displayName: string;
|
|
||||||
iconDataUri: string | null;
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
drag: () => void;
|
|
||||||
isDragging: boolean;
|
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<NonGitProjectRowWithMenuContent {...props} />
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FlattenedProjectRow({
|
function FlattenedProjectRow({
|
||||||
project,
|
project,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -1378,6 +1269,8 @@ function FlattenedProjectRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1393,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}
|
||||||
@@ -1431,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1601,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" ? (
|
||||||
@@ -1625,6 +1546,8 @@ function ProjectBlock({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
|
onRemoveProject={handleRemoveProject}
|
||||||
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1643,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2144,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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -73,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;
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,8 @@ export interface ComboboxItemProps {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
disabled?: 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;
|
||||||
}
|
}
|
||||||
@@ -163,6 +171,7 @@ export function ComboboxItem({
|
|||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
|
elevated,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}: ComboboxItemProps): ReactElement {
|
}: ComboboxItemProps): ReactElement {
|
||||||
@@ -187,19 +196,19 @@ export function ComboboxItem({
|
|||||||
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,
|
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}
|
||||||
@@ -246,6 +255,9 @@ export function Combobox({
|
|||||||
stackBehavior,
|
stackBehavior,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
|
desktopMinWidth,
|
||||||
|
desktopFixedHeight,
|
||||||
|
stickyHeader,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
@@ -390,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;
|
||||||
@@ -662,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"
|
||||||
@@ -687,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}
|
||||||
@@ -701,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" ? (
|
||||||
@@ -783,9 +817,15 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -807,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
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",
|
||||||
@@ -817,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],
|
||||||
@@ -876,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function useWebElementScrollbar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
element.addEventListener("scroll", update, { passive: true });
|
element.addEventListener("scroll", update, { passive: true });
|
||||||
element.addEventListener("input", update, { passive: true });
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(update);
|
const resizeObserver = new ResizeObserver(update);
|
||||||
resizeObserver.observe(element);
|
resizeObserver.observe(element);
|
||||||
@@ -90,7 +89,6 @@ export function useWebElementScrollbar(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.removeEventListener("scroll", update);
|
element.removeEventListener("scroll", update);
|
||||||
element.removeEventListener("input", update);
|
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
element.removeAttribute("data-hide-scrollbar");
|
element.removeAttribute("data-hide-scrollbar");
|
||||||
(element.style as any).scrollbarWidth = "";
|
(element.style as any).scrollbarWidth = "";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { 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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
type AgentScreenMissingState,
|
type AgentScreenMissingState,
|
||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -704,27 +703,6 @@ function AgentPanelBody({
|
|||||||
shouldUseOptimisticStream,
|
shouldUseOptimisticStream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isHistoryRefreshCatchingUp =
|
|
||||||
viewState.tag === "ready" &&
|
|
||||||
viewState.sync.status === "catching_up" &&
|
|
||||||
viewState.sync.ui === "toast";
|
|
||||||
const shouldEmitSyncErrorToast =
|
|
||||||
viewState.tag === "ready" &&
|
|
||||||
viewState.sync.status === "sync_error" &&
|
|
||||||
viewState.sync.shouldEmitSyncErrorToast;
|
|
||||||
|
|
||||||
useDelayedHistoryRefreshToast({
|
|
||||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
|
||||||
indicatorColor: theme.colors.primary,
|
|
||||||
showToast: panelToast.api.show,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldEmitSyncErrorToast) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
|
||||||
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
|
||||||
|
|
||||||
if (viewState.tag === "not_found") {
|
if (viewState.tag === "not_found") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
|
|||||||
isGit: false,
|
isGit: false,
|
||||||
currentBranch: null,
|
currentBranch: null,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
mainRepoRoot: null,
|
mainRepoRoot: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from "@/types/host-connection";
|
} from "@/types/host-connection";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||||
import {
|
import {
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
clientId,
|
clientId,
|
||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
|
appVersion: resolveAppVersion() ?? undefined,
|
||||||
runtimeGeneration,
|
runtimeGeneration,
|
||||||
};
|
};
|
||||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
@@ -1681,6 +1683,41 @@ export class HostRuntimeStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const isAnyOnline = (): boolean => {
|
||||||
|
for (const host of this.hosts) {
|
||||||
|
const snapshot = this.getSnapshot(host.serverId);
|
||||||
|
if (snapshot?.connectionStatus === "online") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
if (isAnyOnline()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = this.subscribeAll(() => {
|
||||||
|
if (isAnyOnline()) {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
cancel: () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ensureConnectedAll(): void {
|
ensureConnectedAll(): void {
|
||||||
for (const controller of this.controllers.values()) {
|
for (const controller of this.controllers.values()) {
|
||||||
controller.ensureConnected();
|
controller.ensureConnected();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -219,6 +220,7 @@ function DraftAgentScreenContent({
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
allProviderEntries,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
@@ -757,6 +759,18 @@ function DraftAgentScreenContent({
|
|||||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
|
const {
|
||||||
|
features: draftFeatures,
|
||||||
|
featureValues: draftFeatureValues,
|
||||||
|
setFeatureValue: setDraftFeatureValue,
|
||||||
|
} = useDraftAgentFeatures({
|
||||||
|
serverId: selectedServerId,
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workingDir,
|
||||||
|
modeId: selectedMode,
|
||||||
|
modelId: effectiveDraftModelId,
|
||||||
|
thinkingOptionId: effectiveDraftThinkingOptionId,
|
||||||
|
});
|
||||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||||
const cwd = (
|
const cwd = (
|
||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||||
@@ -773,8 +787,10 @@ function DraftAgentScreenContent({
|
|||||||
...(effectiveDraftThinkingOptionId
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
draftFeatureValues,
|
||||||
effectiveDraftModelId,
|
effectiveDraftModelId,
|
||||||
effectiveDraftThinkingOptionId,
|
effectiveDraftThinkingOptionId,
|
||||||
isAttachWorktree,
|
isAttachWorktree,
|
||||||
@@ -878,6 +894,7 @@ function DraftAgentScreenContent({
|
|||||||
title: "New agent",
|
title: "New agent",
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
|
features: draftFeatures,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
@@ -896,6 +913,7 @@ function DraftAgentScreenContent({
|
|||||||
...(effectiveDraftThinkingOptionId
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveBaseBranch = baseBranch.trim();
|
const effectiveBaseBranch = baseBranch.trim();
|
||||||
@@ -1248,6 +1266,8 @@ function DraftAgentScreenContent({
|
|||||||
thinkingOptions: availableThinkingOptions,
|
thinkingOptions: availableThinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||||
|
features: draftFeatures,
|
||||||
|
onSetFeature: setDraftFeatureValue,
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
|||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
@@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
flexGrow: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 0,
|
gap: 0,
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
|
paddingBottom: {
|
||||||
|
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||||
|
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
logo: {
|
logo: {
|
||||||
marginBottom: theme.spacing[8],
|
marginBottom: theme.spacing[8],
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
leftIcon={ChevronLeft}
|
leftIcon={ChevronLeft}
|
||||||
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
|
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
Stethoscope,
|
Stethoscope,
|
||||||
Info,
|
Info,
|
||||||
Shield,
|
Shield,
|
||||||
|
Puzzle,
|
||||||
|
Blocks,
|
||||||
|
Smartphone,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||||
@@ -50,7 +53,9 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
|
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
|
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
@@ -59,7 +64,12 @@ import { settingsStyles } from "@/styles/settings";
|
|||||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section definitions
|
// Section definitions
|
||||||
@@ -69,10 +79,13 @@ type SettingsSectionId =
|
|||||||
| "hosts"
|
| "hosts"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
|
| "integrations"
|
||||||
|
| "providers"
|
||||||
| "diagnostics"
|
| "diagnostics"
|
||||||
| "about"
|
| "about"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
| "daemon";
|
| "daemon"
|
||||||
|
| "pair-device";
|
||||||
|
|
||||||
interface SettingsSectionDef {
|
interface SettingsSectionDef {
|
||||||
id: SettingsSectionId;
|
id: SettingsSectionId;
|
||||||
@@ -85,17 +98,23 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
|||||||
{ id: "hosts", label: "Hosts", icon: Server },
|
{ id: "hosts", label: "Hosts", icon: Server },
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||||
{ id: "about", label: "About", icon: Info },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (context.isDesktopApp) {
|
if (context.isDesktopApp) {
|
||||||
sections.push(
|
sections.push(
|
||||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||||
|
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
{ id: "providers", label: "Providers", icon: Blocks },
|
||||||
|
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||||
|
{ id: "about", label: "About", icon: Info },
|
||||||
|
);
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +196,10 @@ interface HostsSectionProps {
|
|||||||
routeServerId: string;
|
routeServerId: string;
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||||
handleEditDaemon: (profile: HostProfile) => void;
|
handleEditDaemon: (profile: HostProfile) => void;
|
||||||
setAddConnectionTargetServerId: (id: string | null) => void;
|
|
||||||
setPendingEditReopenServerId: (id: string | null) => void;
|
|
||||||
setIsAddHostMethodVisible: (visible: boolean) => void;
|
setIsAddHostMethodVisible: (visible: boolean) => void;
|
||||||
isAddHostMethodVisible: boolean;
|
isAddHostMethodVisible: boolean;
|
||||||
isDirectHostVisible: boolean;
|
isDirectHostVisible: boolean;
|
||||||
isPasteLinkVisible: boolean;
|
isPasteLinkVisible: boolean;
|
||||||
addConnectionTargetServerId: string | null;
|
|
||||||
closeAddConnectionFlow: () => void;
|
closeAddConnectionFlow: () => void;
|
||||||
goBackToAddConnectionMethods: () => void;
|
goBackToAddConnectionMethods: () => void;
|
||||||
setIsDirectHostVisible: (visible: boolean) => void;
|
setIsDirectHostVisible: (visible: boolean) => void;
|
||||||
@@ -203,7 +219,6 @@ interface HostsSectionProps {
|
|||||||
handleSaveEditDaemon: (label: string) => Promise<void>;
|
handleSaveEditDaemon: (label: string) => Promise<void>;
|
||||||
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
handleRemoveDaemon: (profile: HostProfile) => void;
|
handleRemoveDaemon: (profile: HostProfile) => void;
|
||||||
handleAddConnectionFromModal: () => void;
|
|
||||||
restartConfirmationMessage: string;
|
restartConfirmationMessage: string;
|
||||||
waitForCondition: (
|
waitForCondition: (
|
||||||
predicate: () => boolean,
|
predicate: () => boolean,
|
||||||
@@ -243,8 +258,6 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
textStyle={styles.addButtonText}
|
textStyle={styles.addButtonText}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
props.setAddConnectionTargetServerId(null);
|
|
||||||
props.setPendingEditReopenServerId(null);
|
|
||||||
props.setIsAddHostMethodVisible(true);
|
props.setIsAddHostMethodVisible(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -264,22 +277,17 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
props.setIsPasteLinkVisible(true);
|
props.setIsPasteLinkVisible(true);
|
||||||
}}
|
}}
|
||||||
onScanQr={() => {
|
onScanQr={() => {
|
||||||
const targetServerId = props.addConnectionTargetServerId;
|
const sourceServerId = props.routeServerId || undefined;
|
||||||
const source = targetServerId ? "editHost" : "settings";
|
|
||||||
const sourceServerId = props.routeServerId || targetServerId || undefined;
|
|
||||||
props.closeAddConnectionFlow();
|
props.closeAddConnectionFlow();
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/pair-scan",
|
pathname: "/pair-scan",
|
||||||
params: targetServerId
|
params: { source: "settings", sourceServerId },
|
||||||
? { source, targetServerId, sourceServerId }
|
|
||||||
: { source, sourceServerId },
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddHostModal
|
<AddHostModal
|
||||||
visible={props.isDirectHostVisible}
|
visible={props.isDirectHostVisible}
|
||||||
targetServerId={props.addConnectionTargetServerId ?? undefined}
|
|
||||||
onClose={props.closeAddConnectionFlow}
|
onClose={props.closeAddConnectionFlow}
|
||||||
onCancel={props.goBackToAddConnectionMethods}
|
onCancel={props.goBackToAddConnectionMethods}
|
||||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
@@ -291,7 +299,6 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
|
|
||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={props.isPasteLinkVisible}
|
visible={props.isPasteLinkVisible}
|
||||||
targetServerId={props.addConnectionTargetServerId ?? undefined}
|
|
||||||
onClose={props.closeAddConnectionFlow}
|
onClose={props.closeAddConnectionFlow}
|
||||||
onCancel={props.goBackToAddConnectionMethods}
|
onCancel={props.goBackToAddConnectionMethods}
|
||||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
@@ -371,7 +378,6 @@ function HostsSection(props: HostsSectionProps) {
|
|||||||
onSave={(label) => void props.handleSaveEditDaemon(label)}
|
onSave={(label) => void props.handleSaveEditDaemon(label)}
|
||||||
onRemoveConnection={props.handleRemoveConnection}
|
onRemoveConnection={props.handleRemoveConnection}
|
||||||
onRemoveHost={props.handleRemoveDaemon}
|
onRemoveHost={props.handleRemoveDaemon}
|
||||||
onAddConnection={props.handleAddConnectionFromModal}
|
|
||||||
restartConfirmationMessage={props.restartConfirmationMessage}
|
restartConfirmationMessage={props.restartConfirmationMessage}
|
||||||
waitForCondition={props.waitForCondition}
|
waitForCondition={props.waitForCondition}
|
||||||
isScreenMountedRef={props.isMountedRef}
|
isScreenMountedRef={props.isMountedRef}
|
||||||
@@ -424,6 +430,123 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface ProvidersSectionProps {
|
||||||
|
routeServerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||||
|
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||||
|
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasServer = routeServerId.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={settingsStyles.section}>
|
||||||
|
<View style={settingsStyles.sectionHeader}>
|
||||||
|
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||||
|
{hasServer && isConnected ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={refresh}
|
||||||
|
disabled={isFetching}
|
||||||
|
style={[
|
||||||
|
settingsStyles.sectionHeaderLink,
|
||||||
|
isFetching ? { opacity: 0.5 } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{!hasServer || !isConnected ? (
|
||||||
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
|
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoading ? (
|
||||||
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
|
<Text style={styles.emptyText}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||||
|
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||||
|
const entry = entries?.find((e) => e.provider === def.id);
|
||||||
|
const status = entry?.status ?? "unavailable";
|
||||||
|
const ProviderIcon = getProviderIcon(def.id);
|
||||||
|
const providerError =
|
||||||
|
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||||
|
? entry.error.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={def.id} style={styles.audioRow}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||||
|
>
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||||
|
</View>
|
||||||
|
{providerError ? (
|
||||||
|
<Text style={styles.aboutErrorText} numberOfLines={3}>
|
||||||
|
{providerError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.providerActions}>
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
status === "ready"
|
||||||
|
? "Available"
|
||||||
|
: status === "error"
|
||||||
|
? "Error"
|
||||||
|
: status === "loading"
|
||||||
|
? "Loading..."
|
||||||
|
: "Not installed"
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
status === "ready"
|
||||||
|
? "success"
|
||||||
|
: status === "error"
|
||||||
|
? "error"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setDiagnosticProvider(def.id)}
|
||||||
|
>
|
||||||
|
Diagnostic
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{diagnosticProvider ? (
|
||||||
|
<ProviderDiagnosticSheet
|
||||||
|
provider={diagnosticProvider}
|
||||||
|
visible
|
||||||
|
onClose={() => setDiagnosticProvider(null)}
|
||||||
|
serverId={routeServerId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DiagnosticsSectionProps {
|
interface DiagnosticsSectionProps {
|
||||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||||
isPlaybackTestRunning: boolean;
|
isPlaybackTestRunning: boolean;
|
||||||
@@ -492,6 +615,7 @@ interface SettingsSectionContentProps {
|
|||||||
sectionId: SettingsSectionId;
|
sectionId: SettingsSectionId;
|
||||||
hostsProps: HostsSectionProps;
|
hostsProps: HostsSectionProps;
|
||||||
appearanceProps: AppearanceSectionProps;
|
appearanceProps: AppearanceSectionProps;
|
||||||
|
providersProps: ProvidersSectionProps;
|
||||||
diagnosticsProps: DiagnosticsSectionProps;
|
diagnosticsProps: DiagnosticsSectionProps;
|
||||||
aboutProps: AboutSectionProps;
|
aboutProps: AboutSectionProps;
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
@@ -503,6 +627,7 @@ function SettingsSectionContent({
|
|||||||
sectionId,
|
sectionId,
|
||||||
hostsProps,
|
hostsProps,
|
||||||
appearanceProps,
|
appearanceProps,
|
||||||
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
appVersion,
|
appVersion,
|
||||||
@@ -516,12 +641,18 @@ function SettingsSectionContent({
|
|||||||
return <AppearanceSection {...appearanceProps} />;
|
return <AppearanceSection {...appearanceProps} />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
return <KeyboardShortcutsSection />;
|
return <KeyboardShortcutsSection />;
|
||||||
|
case "providers":
|
||||||
|
return <ProvidersSection {...providersProps} />;
|
||||||
case "diagnostics":
|
case "diagnostics":
|
||||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||||
case "about":
|
case "about":
|
||||||
return <AboutSection {...aboutProps} />;
|
return <AboutSection {...aboutProps} />;
|
||||||
|
case "integrations":
|
||||||
|
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||||
case "permissions":
|
case "permissions":
|
||||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||||
|
case "pair-device":
|
||||||
|
return isDesktopApp ? <PairDeviceSection /> : null;
|
||||||
case "daemon":
|
case "daemon":
|
||||||
return isDesktopApp ? (
|
return isDesktopApp ? (
|
||||||
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
||||||
@@ -570,31 +701,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
|
|||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const isSelected = section.id === selectedSectionId;
|
const isSelected = section.id === selectedSectionId;
|
||||||
const IconComponent = section.icon;
|
const IconComponent = section.icon;
|
||||||
|
const showSeparator =
|
||||||
|
section.id === "integrations" || section.id === "providers";
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<View key={section.id}>
|
||||||
key={section.id}
|
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||||
style={[
|
<Pressable
|
||||||
desktopStyles.sidebarItem,
|
|
||||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
|
||||||
]}
|
|
||||||
onPress={() => setSelectedSectionId(section.id)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ selected: isSelected }}
|
|
||||||
>
|
|
||||||
<IconComponent
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
style={[
|
||||||
desktopStyles.sidebarLabel,
|
desktopStyles.sidebarItem,
|
||||||
isSelected && { color: theme.colors.foreground },
|
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
onPress={() => setSelectedSectionId(section.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: isSelected }}
|
||||||
>
|
>
|
||||||
{section.label}
|
<IconComponent
|
||||||
</Text>
|
size={theme.iconSize.md}
|
||||||
</Pressable>
|
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
desktopStyles.sidebarLabel,
|
||||||
|
isSelected && { color: theme.colors.foreground },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -728,10 +863,6 @@ export default function SettingsScreen() {
|
|||||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||||
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
|
|
||||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
serverId: string;
|
serverId: string;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
@@ -810,7 +941,6 @@ export default function SettingsScreen() {
|
|||||||
setIsAddHostMethodVisible(false);
|
setIsAddHostMethodVisible(false);
|
||||||
setIsDirectHostVisible(false);
|
setIsDirectHostVisible(false);
|
||||||
setIsPasteLinkVisible(false);
|
setIsPasteLinkVisible(false);
|
||||||
setAddConnectionTargetServerId(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goBackToAddConnectionMethods = useCallback(() => {
|
const goBackToAddConnectionMethods = useCallback(() => {
|
||||||
@@ -829,24 +959,6 @@ export default function SettingsScreen() {
|
|||||||
handleEditDaemon(profile);
|
handleEditDaemon(profile);
|
||||||
}, [daemons, handleEditDaemon, params.editHost]);
|
}, [daemons, handleEditDaemon, params.editHost]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingEditReopenServerId) return;
|
|
||||||
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
|
|
||||||
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
|
|
||||||
setPendingEditReopenServerId(null);
|
|
||||||
setAddConnectionTargetServerId(null);
|
|
||||||
if (profile) {
|
|
||||||
handleEditDaemon(profile);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
daemons,
|
|
||||||
handleEditDaemon,
|
|
||||||
isAddHostMethodVisible,
|
|
||||||
isDirectHostVisible,
|
|
||||||
isPasteLinkVisible,
|
|
||||||
pendingEditReopenServerId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSaveEditDaemon = useCallback(
|
const handleSaveEditDaemon = useCallback(
|
||||||
async (nextLabelRaw: string) => {
|
async (nextLabelRaw: string) => {
|
||||||
if (!editingServerId) return;
|
if (!editingServerId) return;
|
||||||
@@ -884,15 +996,6 @@ export default function SettingsScreen() {
|
|||||||
setPendingRemoveHost(profile);
|
setPendingRemoveHost(profile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddConnectionFromModal = useCallback(() => {
|
|
||||||
if (!editingServerId) return;
|
|
||||||
const serverId = editingServerId;
|
|
||||||
setEditingDaemon(null);
|
|
||||||
setAddConnectionTargetServerId(serverId);
|
|
||||||
setPendingEditReopenServerId(serverId);
|
|
||||||
setIsAddHostMethodVisible(true);
|
|
||||||
}, [editingServerId]);
|
|
||||||
|
|
||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(newTheme: AppSettings["theme"]) => {
|
(newTheme: AppSettings["theme"]) => {
|
||||||
void updateSettings({ theme: newTheme });
|
void updateSettings({ theme: newTheme });
|
||||||
@@ -938,13 +1041,10 @@ export default function SettingsScreen() {
|
|||||||
routeServerId,
|
routeServerId,
|
||||||
theme,
|
theme,
|
||||||
handleEditDaemon,
|
handleEditDaemon,
|
||||||
setAddConnectionTargetServerId,
|
|
||||||
setPendingEditReopenServerId,
|
|
||||||
setIsAddHostMethodVisible,
|
setIsAddHostMethodVisible,
|
||||||
isAddHostMethodVisible,
|
isAddHostMethodVisible,
|
||||||
isDirectHostVisible,
|
isDirectHostVisible,
|
||||||
isPasteLinkVisible,
|
isPasteLinkVisible,
|
||||||
addConnectionTargetServerId,
|
|
||||||
closeAddConnectionFlow,
|
closeAddConnectionFlow,
|
||||||
goBackToAddConnectionMethods,
|
goBackToAddConnectionMethods,
|
||||||
setIsDirectHostVisible,
|
setIsDirectHostVisible,
|
||||||
@@ -964,7 +1064,6 @@ export default function SettingsScreen() {
|
|||||||
handleSaveEditDaemon,
|
handleSaveEditDaemon,
|
||||||
handleRemoveConnection,
|
handleRemoveConnection,
|
||||||
handleRemoveDaemon,
|
handleRemoveDaemon,
|
||||||
handleAddConnectionFromModal,
|
|
||||||
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
|
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
|
||||||
waitForCondition,
|
waitForCondition,
|
||||||
isMountedRef,
|
isMountedRef,
|
||||||
@@ -987,9 +1086,14 @@ export default function SettingsScreen() {
|
|||||||
isDesktopApp,
|
isDesktopApp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const providersProps: ProvidersSectionProps = {
|
||||||
|
routeServerId,
|
||||||
|
};
|
||||||
|
|
||||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||||
hostsProps,
|
hostsProps,
|
||||||
appearanceProps,
|
appearanceProps,
|
||||||
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
appVersion,
|
appVersion,
|
||||||
@@ -1029,7 +1133,6 @@ interface HostDetailModalProps {
|
|||||||
onSave: (label: string) => void;
|
onSave: (label: string) => void;
|
||||||
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
onRemoveHost: (host: HostProfile) => void;
|
onRemoveHost: (host: HostProfile) => void;
|
||||||
onAddConnection: () => void;
|
|
||||||
restartConfirmationMessage: string;
|
restartConfirmationMessage: string;
|
||||||
waitForCondition: (
|
waitForCondition: (
|
||||||
predicate: () => boolean,
|
predicate: () => boolean,
|
||||||
@@ -1047,7 +1150,6 @@ function HostDetailModal({
|
|||||||
onSave,
|
onSave,
|
||||||
onRemoveConnection,
|
onRemoveConnection,
|
||||||
onRemoveHost,
|
onRemoveHost,
|
||||||
onAddConnection,
|
|
||||||
restartConfirmationMessage,
|
restartConfirmationMessage,
|
||||||
waitForCondition,
|
waitForCondition,
|
||||||
isScreenMountedRef,
|
isScreenMountedRef,
|
||||||
@@ -1278,15 +1380,6 @@ function HostDetailModal({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="md"
|
|
||||||
style={styles.addButton}
|
|
||||||
textStyle={styles.addButtonText}
|
|
||||||
onPress={onAddConnection}
|
|
||||||
>
|
|
||||||
+ Add connection
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1808,6 +1901,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
|
providerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
aboutValue: {
|
aboutValue: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -1893,6 +1991,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
sidebarSeparator: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
marginVertical: theme.spacing[2],
|
||||||
|
marginHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
contentPane: {
|
contentPane: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={settingsStyles.sectionHeader}>
|
||||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
||||||
{hasOverrides && (
|
{hasOverrides && (
|
||||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||||
Reset all
|
Reset all
|
||||||
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
subsectionTitle: {
|
subsectionTitle: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
|
||||||
const groups = classifyBulkClosableTabs([
|
const groups = classifyBulkClosableTabs([
|
||||||
makeAgentTab("a1"),
|
makeAgentTab("a1"),
|
||||||
makeTerminalTab("t1"),
|
makeTerminalTab("t1"),
|
||||||
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
requestId: "req-1",
|
requestId: "req-1",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const result = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
groups,
|
groups,
|
||||||
client: { closeItems },
|
client: { closeItems },
|
||||||
closeTab: async (tabId, action) => {
|
closeTab: async (tabId, action) => {
|
||||||
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
|
|||||||
agentIds: ["a1"],
|
agentIds: ["a1"],
|
||||||
terminalIds: ["t1", "t2"],
|
terminalIds: ["t1", "t2"],
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(closedTabIds).toEqual([
|
||||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
"agent_a1",
|
||||||
terminals: [
|
"terminal_t1",
|
||||||
{ terminalId: "t1", success: true },
|
"terminal_t2",
|
||||||
{ terminalId: "t2", success: false },
|
"file_/repo/README.md",
|
||||||
],
|
]);
|
||||||
requestId: "req-1",
|
|
||||||
});
|
|
||||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
|
||||||
expect(cleanupCalls).toEqual([
|
expect(cleanupCalls).toEqual([
|
||||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||||
|
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||||
{ tabId: "file_/repo/README.md" },
|
{ tabId: "file_/repo/README.md" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
|
||||||
const groups = classifyBulkClosableTabs([
|
const groups = classifyBulkClosableTabs([
|
||||||
makeAgentTab("a1"),
|
makeAgentTab("a1"),
|
||||||
makeTerminalTab("t1"),
|
makeTerminalTab("t1"),
|
||||||
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||||
const warn = vi.fn();
|
const warn = vi.fn();
|
||||||
|
|
||||||
const result = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
groups,
|
groups,
|
||||||
client: {
|
client: {
|
||||||
closeItems: async () => {
|
closeItems: async () => {
|
||||||
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
|
|||||||
logLabel: "others",
|
logLabel: "others",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(warn).toHaveBeenCalledTimes(1);
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toBeNull();
|
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
expect(cleanupCalls).toEqual([
|
||||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||||
|
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||||
|
{ tabId: "file_/repo/README.md" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
|
|||||||
otherTabs: Array<{ tabId: string }>;
|
otherTabs: Array<{ tabId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
|
||||||
|
|
||||||
interface CloseWorkspaceTabWithCleanupInput {
|
interface CloseWorkspaceTabWithCleanupInput {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
target?: WorkspaceTabDescriptor["target"];
|
target?: WorkspaceTabDescriptor["target"];
|
||||||
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
|||||||
return `This will archive ${agentTabs.length} agent(s).`;
|
return `This will archive ${agentTabs.length} agent(s).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
|
||||||
return new Set(
|
|
||||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeBulkWorkspaceTabs(
|
|
||||||
input: CloseBulkWorkspaceTabsInput,
|
|
||||||
): Promise<CloseItemsPayload | null> {
|
|
||||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||||
let payload: CloseItemsPayload | null = null;
|
|
||||||
|
|
||||||
if (hasDestructiveTabs && client) {
|
if (hasDestructiveTabs && client) {
|
||||||
try {
|
void client
|
||||||
payload = await client.closeItems({
|
.closeItems({
|
||||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
|
||||||
}
|
|
||||||
} else if (hasDestructiveTabs) {
|
} else if (hasDestructiveTabs) {
|
||||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||||
error: new Error("Daemon client not available"),
|
error: new Error("Daemon client not available"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
|
||||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
|
||||||
|
|
||||||
for (const { tabId, agentId } of groups.agentTabs) {
|
for (const { tabId, agentId } of groups.agentTabs) {
|
||||||
if (!successfulAgentIds.has(agentId)) {
|
void closeTab(tabId, async () => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await closeTab(tabId, async () => {
|
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||||
if (!successfulTerminalIds.has(terminalId)) {
|
void closeTab(tabId, async () => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await closeTab(tabId, async () => {
|
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const { tabId } of groups.otherTabs) {
|
for (const { tabId } of groups.otherTabs) {
|
||||||
await closeTab(tabId, async () => {
|
void closeTab(tabId, async () => {
|
||||||
closeWorkspaceTabWithCleanup({ tabId });
|
closeWorkspaceTabWithCleanup({ tabId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
Columns2,
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
|
RotateCw,
|
||||||
Rows2,
|
Rows2,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -71,6 +72,7 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
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) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
@@ -291,11 +293,14 @@ function TabChip({
|
|||||||
disabled={entry.disabled}
|
disabled={entry.disabled}
|
||||||
destructive={entry.destructive}
|
destructive={entry.destructive}
|
||||||
onSelect={entry.onSelect}
|
onSelect={entry.onSelect}
|
||||||
|
tooltip={entry.tooltip}
|
||||||
leading={(() => {
|
leading={(() => {
|
||||||
const iconColor = theme.colors.foregroundMuted;
|
const iconColor = theme.colors.foregroundMuted;
|
||||||
switch (entry.icon) {
|
switch (entry.icon) {
|
||||||
case "copy":
|
case "copy":
|
||||||
return <Copy size={16} color={iconColor} />;
|
return <Copy size={16} color={iconColor} />;
|
||||||
|
case "rotate-cw":
|
||||||
|
return <RotateCw size={16} color={iconColor} />;
|
||||||
case "arrow-left-to-line":
|
case "arrow-left-to-line":
|
||||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||||
case "arrow-right-to-line":
|
case "arrow-right-to-line":
|
||||||
@@ -335,6 +340,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -457,6 +463,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -585,6 +592,7 @@ function ResolvedDesktopTabChip({
|
|||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -608,6 +616,7 @@ function ResolvedDesktopTabChip({
|
|||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
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) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
@@ -630,6 +639,7 @@ function ResolvedDesktopTabChip({
|
|||||||
tabCount,
|
tabCount,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
@@ -644,6 +654,7 @@ function ResolvedDesktopTabChip({
|
|||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
|
onReloadAgent,
|
||||||
tabCount,
|
tabCount,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ImageAttachment } from "@/components/message-input";
|
|||||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
@@ -74,6 +75,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
allProviderEntries,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
@@ -110,6 +112,18 @@ export function WorkspaceDraftAgentTab({
|
|||||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
|
const {
|
||||||
|
features: draftFeatures,
|
||||||
|
featureValues: draftFeatureValues,
|
||||||
|
setFeatureValue: setDraftFeatureValue,
|
||||||
|
} = useDraftAgentFeatures({
|
||||||
|
serverId,
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
modeId: selectedMode,
|
||||||
|
modelId: effectiveDraftModelId,
|
||||||
|
thinkingOptionId: effectiveDraftThinkingOptionId,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -168,6 +182,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
model,
|
model,
|
||||||
|
features: draftFeatures,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
@@ -186,6 +201,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
...(effectiveDraftThinkingOptionId
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
@@ -215,8 +231,10 @@ export function WorkspaceDraftAgentTab({
|
|||||||
...(effectiveDraftThinkingOptionId
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
draftFeatureValues,
|
||||||
effectiveDraftModelId,
|
effectiveDraftModelId,
|
||||||
effectiveDraftThinkingOptionId,
|
effectiveDraftThinkingOptionId,
|
||||||
modeOptions.length,
|
modeOptions.length,
|
||||||
@@ -233,6 +251,60 @@ export function WorkspaceDraftAgentTab({
|
|||||||
addImagesRef.current = addImages;
|
addImagesRef.current = addImages;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const focusInputRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const handleFocusInputCallback = useCallback((focus: () => void) => {
|
||||||
|
focusInputRef.current = focus;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProviderSelectWithFocus = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
setProviderFromUser(provider);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setProviderFromUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModeSelectWithFocus = useCallback(
|
||||||
|
(modeId: string) => {
|
||||||
|
setModeFromUser(modeId);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setModeFromUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModelSelectWithFocus = useCallback(
|
||||||
|
(modelId: string) => {
|
||||||
|
setModelFromUser(modelId);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setModelFromUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProviderAndModelSelectWithFocus = useCallback(
|
||||||
|
(provider: string, modelId: string) => {
|
||||||
|
setProviderAndModelFromUser(provider, modelId);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setProviderAndModelFromUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThinkingOptionSelectWithFocus = useCallback(
|
||||||
|
(optionId: string) => {
|
||||||
|
setThinkingOptionFromUser(optionId);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setThinkingOptionFromUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetFeatureWithFocus = useCallback(
|
||||||
|
(featureId: string, value: unknown) => {
|
||||||
|
setDraftFeatureValue(featureId, value);
|
||||||
|
focusInputRef.current?.();
|
||||||
|
},
|
||||||
|
[setDraftFeatureValue],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -279,24 +351,28 @@ export function WorkspaceDraftAgentTab({
|
|||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
|
onFocusInput={handleFocusInputCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
onSelectProvider: setProviderFromUser,
|
onSelectProvider: handleProviderSelectWithFocus,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
onSelectMode: setModeFromUser,
|
onSelectMode: handleModeSelectWithFocus,
|
||||||
models: availableModels,
|
models: availableModels,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onSelectModel: setModelFromUser,
|
onSelectModel: handleModelSelectWithFocus,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
|
||||||
thinkingOptions: availableThinkingOptions,
|
thinkingOptions: availableThinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
|
||||||
|
features: draftFeatures,
|
||||||
|
onSetFeature: handleSetFeatureWithFocus,
|
||||||
|
onDropdownClose: () => focusInputRef.current?.(),
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronDown } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import type {
|
||||||
|
EditorTargetDescriptorPayload,
|
||||||
|
EditorTargetId,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import {
|
||||||
|
resolvePreferredEditorId,
|
||||||
|
usePreferredEditor,
|
||||||
|
} from "@/hooks/use-preferred-editor";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
|
interface WorkspaceOpenInEditorButtonProps {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceOpenInEditorButton({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
}: WorkspaceOpenInEditorButtonProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||||
|
|
||||||
|
const shouldLoadEditors =
|
||||||
|
Platform.OS === "web" &&
|
||||||
|
Boolean(client && isConnected) &&
|
||||||
|
cwd.trim().length > 0 &&
|
||||||
|
isAbsolutePath(cwd);
|
||||||
|
|
||||||
|
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||||
|
queryKey: ["available-editors", serverId],
|
||||||
|
enabled: shouldLoadEditors,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await client.listAvailableEditors();
|
||||||
|
return payload.error ? [] : payload.editors;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableEditors = availableEditorsQuery.data ?? [];
|
||||||
|
const availableEditorIds = useMemo(
|
||||||
|
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||||
|
[availableEditors],
|
||||||
|
);
|
||||||
|
const effectivePreferredEditorId = useMemo(
|
||||||
|
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||||
|
[availableEditorIds, preferredEditorId],
|
||||||
|
);
|
||||||
|
const primaryOption =
|
||||||
|
availableEditors.find(
|
||||||
|
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
||||||
|
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||||
|
|
||||||
|
const openMutation = useMutation({
|
||||||
|
mutationFn: async (editorId: EditorTargetId) => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
const payload = await client.openInEditor(cwd, editorId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return editorId;
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenEditor = useCallback(
|
||||||
|
(editorId: EditorTargetId) => {
|
||||||
|
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||||
|
openMutation.mutate(editorId);
|
||||||
|
},
|
||||||
|
[openMutation, updatePreferredEditor],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.splitButton}>
|
||||||
|
<Pressable
|
||||||
|
testID="workspace-open-in-editor-primary"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.splitButtonPrimary,
|
||||||
|
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||||
|
openMutation.isPending && styles.splitButtonPrimaryDisabled,
|
||||||
|
]}
|
||||||
|
onPress={() => handleOpenEditor(primaryOption.id)}
|
||||||
|
disabled={openMutation.isPending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||||
|
>
|
||||||
|
{openMutation.isPending ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foreground}
|
||||||
|
style={styles.splitButtonSpinnerOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.splitButtonContent}>
|
||||||
|
<EditorAppIcon
|
||||||
|
editorId={primaryOption.id}
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.splitButtonText}>Open</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{availableEditors.length > 1 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="workspace-open-in-editor-caret"
|
||||||
|
style={({ hovered, pressed, open }) => [
|
||||||
|
styles.splitButtonCaret,
|
||||||
|
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Choose editor"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
minWidth={148}
|
||||||
|
maxWidth={176}
|
||||||
|
testID="workspace-open-in-editor-menu"
|
||||||
|
>
|
||||||
|
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={editor.id}
|
||||||
|
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||||
|
leading={
|
||||||
|
<EditorAppIcon
|
||||||
|
editorId={editor.id}
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
trailing={
|
||||||
|
editor.id === effectivePreferredEditorId
|
||||||
|
? <Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSelect={() => handleOpenEditor(editor.id)}
|
||||||
|
>
|
||||||
|
{editor.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
splitButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
splitButtonPrimary: {
|
||||||
|
paddingLeft: theme.spacing[3],
|
||||||
|
paddingRight: 10,
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
splitButtonPrimaryHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
splitButtonPrimaryDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
splitButtonText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.5,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
splitButtonContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
splitButtonSpinnerOnly: {
|
||||||
|
transform: [{ scale: 0.8 }],
|
||||||
|
},
|
||||||
|
splitButtonCaret: {
|
||||||
|
width: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderLeftWidth: theme.borderWidth[1],
|
||||||
|
borderLeftColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
splitButtonCaretHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Ellipsis,
|
Ellipsis,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
|
RotateCw,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
@@ -47,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
|||||||
import { SplitContainer } from "@/components/split-container";
|
import { SplitContainer } from "@/components/split-container";
|
||||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||||
|
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -77,7 +79,7 @@ import {
|
|||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
@@ -182,6 +184,7 @@ type MobileWorkspaceTabSwitcherProps = {
|
|||||||
onSelectSwitcherTab: (key: string) => void;
|
onSelectSwitcherTab: (key: string) => 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;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||||
@@ -269,6 +272,7 @@ function MobileWorkspaceTabOption({
|
|||||||
onPress,
|
onPress,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsAbove,
|
onCloseTabsAbove,
|
||||||
onCloseTabsBelow,
|
onCloseTabsBelow,
|
||||||
@@ -284,6 +288,7 @@ function MobileWorkspaceTabOption({
|
|||||||
onPress: () => void;
|
onPress: () => 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;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||||
@@ -299,6 +304,7 @@ function MobileWorkspaceTabOption({
|
|||||||
menuTestIDBase,
|
menuTestIDBase,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsBefore: onCloseTabsAbove,
|
onCloseTabsBefore: onCloseTabsAbove,
|
||||||
onCloseTabsAfter: onCloseTabsBelow,
|
onCloseTabsAfter: onCloseTabsBelow,
|
||||||
@@ -342,11 +348,14 @@ function MobileWorkspaceTabOption({
|
|||||||
disabled={entry.disabled}
|
disabled={entry.disabled}
|
||||||
destructive={entry.destructive}
|
destructive={entry.destructive}
|
||||||
onSelect={entry.onSelect}
|
onSelect={entry.onSelect}
|
||||||
|
tooltip={entry.tooltip}
|
||||||
leading={(() => {
|
leading={(() => {
|
||||||
const iconColor = theme.colors.foregroundMuted;
|
const iconColor = theme.colors.foregroundMuted;
|
||||||
switch (entry.icon) {
|
switch (entry.icon) {
|
||||||
case "copy":
|
case "copy":
|
||||||
return <Copy size={16} color={iconColor} />;
|
return <Copy size={16} color={iconColor} />;
|
||||||
|
case "rotate-cw":
|
||||||
|
return <RotateCw size={16} color={iconColor} />;
|
||||||
case "arrow-left-to-line":
|
case "arrow-left-to-line":
|
||||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||||
case "arrow-right-to-line":
|
case "arrow-right-to-line":
|
||||||
@@ -389,6 +398,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
onSelectSwitcherTab,
|
onSelectSwitcherTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
|
onReloadAgent,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsAbove,
|
onCloseTabsAbove,
|
||||||
onCloseTabsBelow,
|
onCloseTabsBelow,
|
||||||
@@ -460,6 +470,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
|
onReloadAgent={onReloadAgent}
|
||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCloseTabsAbove={onCloseTabsAbove}
|
onCloseTabsAbove={onCloseTabsAbove}
|
||||||
onCloseTabsBelow={onCloseTabsBelow}
|
onCloseTabsBelow={onCloseTabsBelow}
|
||||||
@@ -983,6 +994,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (
|
if (
|
||||||
canPruneAgentTabs &&
|
canPruneAgentTabs &&
|
||||||
tab.target.kind === "agent" &&
|
tab.target.kind === "agent" &&
|
||||||
|
!pinnedAgentIds.has(tab.target.agentId) &&
|
||||||
shouldPruneWorkspaceAgentTab({
|
shouldPruneWorkspaceAgentTab({
|
||||||
agentId: tab.target.agentId,
|
agentId: tab.target.agentId,
|
||||||
agentsHydrated: hasHydratedAgents,
|
agentsHydrated: hasHydratedAgents,
|
||||||
@@ -1202,10 +1214,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await killTerminalAsync(terminalId);
|
|
||||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
|
||||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
|
||||||
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return current;
|
return current;
|
||||||
@@ -1215,13 +1223,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||||
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void killTerminalAsync(terminalId).catch(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -1242,18 +1255,22 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await confirmDialog({
|
const agent =
|
||||||
title: "Archive agent?",
|
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
message: "This closes the tab and archives the agent.",
|
|
||||||
confirmLabel: "Archive",
|
if (agent?.status !== "idle") {
|
||||||
cancelLabel: "Cancel",
|
const confirmed = await confirmDialog({
|
||||||
destructive: true,
|
title: "Archive agent?",
|
||||||
});
|
message: "This closes the tab and archives the agent.",
|
||||||
if (!confirmed) {
|
confirmLabel: "Archive",
|
||||||
return;
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
|
||||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
@@ -1262,6 +1279,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void archiveAgent({ serverId: normalizedServerId, agentId });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||||
@@ -1297,29 +1316,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
const prevCloseTabDeps = useRef({
|
|
||||||
allTabDescriptorsById,
|
|
||||||
handleCloseAgentTab,
|
|
||||||
handleCloseDraftOrFileTab,
|
|
||||||
handleCloseTerminalTab,
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevCloseTabDeps.current;
|
|
||||||
const changed: string[] = [];
|
|
||||||
if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById");
|
|
||||||
if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab");
|
|
||||||
if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab)
|
|
||||||
changed.push("handleCloseDraftOrFileTab");
|
|
||||||
if (prev.handleCloseTerminalTab !== handleCloseTerminalTab)
|
|
||||||
changed.push("handleCloseTerminalTab");
|
|
||||||
if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", "));
|
|
||||||
prevCloseTabDeps.current = {
|
|
||||||
allTabDescriptorsById,
|
|
||||||
handleCloseAgentTab,
|
|
||||||
handleCloseDraftOrFileTab,
|
|
||||||
handleCloseTerminalTab,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleCopyAgentId = useCallback(
|
const handleCopyAgentId = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
@@ -1365,6 +1361,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
[normalizedServerId, toast],
|
[normalizedServerId, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleReloadAgent = useCallback(
|
||||||
|
async (agentId: string) => {
|
||||||
|
if (!client || !isConnected) {
|
||||||
|
toast.error("Host is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.refreshAgent(agentId);
|
||||||
|
toast.show("Reloaded agent", { variant: "success" });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to reload agent");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, isConnected, toast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
||||||
toast.error("Workspace path not available");
|
toast.error("Workspace path not available");
|
||||||
@@ -1412,7 +1425,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeItemsPayload = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
client,
|
client,
|
||||||
groups,
|
groups,
|
||||||
closeTab,
|
closeTab,
|
||||||
@@ -1428,31 +1441,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closeItemsPayload) {
|
|
||||||
for (const terminal of closeItemsPayload.terminals) {
|
|
||||||
if (!terminal.success) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
|
||||||
if (!current) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedServerId) {
|
|
||||||
applyArchivedAgentCloseResults({
|
|
||||||
queryClient,
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
results: closeItemsPayload.agents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||||
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
@@ -1461,10 +1449,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
client,
|
client,
|
||||||
closeTab,
|
closeTab,
|
||||||
closeWorkspaceTabWithCleanup,
|
closeWorkspaceTabWithCleanup,
|
||||||
normalizedServerId,
|
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
queryClient,
|
|
||||||
terminalsQueryKey,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1767,44 +1752,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
retargetWorkspaceTab,
|
retargetWorkspaceTab,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const prevBuildDeps = useRef({
|
|
||||||
handleCloseTabById,
|
|
||||||
handleOpenFileFromChat,
|
|
||||||
focusWorkspacePane,
|
|
||||||
navigateToTabId,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
openWorkspaceTab,
|
|
||||||
persistenceKey,
|
|
||||||
retargetWorkspaceTab,
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevBuildDeps.current;
|
|
||||||
const changed: string[] = [];
|
|
||||||
if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById");
|
|
||||||
if (prev.handleOpenFileFromChat !== handleOpenFileFromChat)
|
|
||||||
changed.push("handleOpenFileFromChat");
|
|
||||||
if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane");
|
|
||||||
if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId");
|
|
||||||
if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId");
|
|
||||||
if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId");
|
|
||||||
if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab");
|
|
||||||
if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey");
|
|
||||||
if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab");
|
|
||||||
if (changed.length > 0)
|
|
||||||
console.log("[buildPaneContentModel] deps changed:", changed.join(", "));
|
|
||||||
prevBuildDeps.current = {
|
|
||||||
handleCloseTabById,
|
|
||||||
handleOpenFileFromChat,
|
|
||||||
focusWorkspacePane,
|
|
||||||
navigateToTabId,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
openWorkspaceTab,
|
|
||||||
persistenceKey,
|
|
||||||
retargetWorkspaceTab,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
|
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
|
||||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||||
@@ -2078,6 +2025,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
|
{!isMobile ? (
|
||||||
|
<WorkspaceOpenInEditorButton
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
cwd={normalizedWorkspaceId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{!isMobile && isGitCheckout ? (
|
{!isMobile && isGitCheckout ? (
|
||||||
<>
|
<>
|
||||||
<WorkspaceGitActions
|
<WorkspaceGitActions
|
||||||
@@ -2210,6 +2163,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
|
onReloadAgent={handleReloadAgent}
|
||||||
onCloseTab={handleCloseTabById}
|
onCloseTab={handleCloseTabById}
|
||||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||||
onCloseTabsBelow={handleCloseTabsToRight}
|
onCloseTabsBelow={handleCloseTabsToRight}
|
||||||
@@ -2230,6 +2184,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
onCloseTab={handleCloseTabById}
|
onCloseTab={handleCloseTabById}
|
||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
|
onReloadAgent={handleReloadAgent}
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||||
onCloseTabsToRight={handleCloseTabsToRight}
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
@@ -2266,6 +2221,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
onCloseTab={handleCloseTabById}
|
onCloseTab={handleCloseTabById}
|
||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
|
onReloadAgent={handleReloadAgent}
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user