mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5631eb17ea | ||
|
|
49e09ee660 | ||
|
|
56b1def06e | ||
|
|
7512110e6f | ||
|
|
cb37d026ad | ||
|
|
50ec8955eb | ||
|
|
a3959dd99f | ||
|
|
27b6242128 | ||
|
|
51d1d007ce | ||
|
|
cfd72b815c | ||
|
|
b3f44981a8 | ||
|
|
e9f9759ba8 | ||
|
|
dcbbaa8ece | ||
|
|
edd5a99832 | ||
|
|
52a66cca70 | ||
|
|
e53d26699f | ||
|
|
c5bcec5c71 | ||
|
|
b83ee957d1 | ||
|
|
9f41904c6f | ||
|
|
eca0a5bf67 | ||
|
|
e72b0773e6 | ||
|
|
dcdb178468 | ||
|
|
bed8af7aa6 | ||
|
|
378f1986ac | ||
|
|
0967557846 | ||
|
|
6c3e2bd703 | ||
|
|
9ce6a38792 | ||
|
|
b832d49a78 | ||
|
|
02ec937399 | ||
|
|
0cbb8238c2 | ||
|
|
211f5b4141 | ||
|
|
2293e0965a | ||
|
|
7c68985cc0 | ||
|
|
899849cdd5 | ||
|
|
409d67ef65 | ||
|
|
b0954d1616 | ||
|
|
9d9f6905c6 | ||
|
|
b04ff0e6b8 | ||
|
|
a6d0046c97 | ||
|
|
a1fbc91163 | ||
|
|
7d748436f4 | ||
|
|
afbb2e8bb8 | ||
|
|
6524677960 | ||
|
|
09a1fe46fe | ||
|
|
06a8f952db | ||
|
|
893e3376b0 | ||
|
|
7c8b290e2f | ||
|
|
d55e1622cb | ||
|
|
69715a77e9 | ||
|
|
e1b27fc584 | ||
|
|
9c16bc474b | ||
|
|
59d48d235a | ||
|
|
6aa73baaab | ||
|
|
0cf1717e04 | ||
|
|
2822c02543 | ||
|
|
db4376d17a | ||
|
|
9a8912b3ef | ||
|
|
fd022bc44b | ||
|
|
7408de6300 | ||
|
|
9b21ccd7f1 | ||
|
|
20c03355f9 | ||
|
|
e09a1591ac | ||
|
|
d35bffed8b | ||
|
|
d454fa3af4 | ||
|
|
59b32ab3be | ||
|
|
44e9287389 | ||
|
|
c89177c211 | ||
|
|
dd329a4f52 | ||
|
|
0d1eecc388 | ||
|
|
7124a82298 | ||
|
|
df635b570a | ||
|
|
1377adbece | ||
|
|
350bc08fc4 | ||
|
|
dfddda7969 | ||
|
|
abf129f56e | ||
|
|
45cca8a406 | ||
|
|
e961ceef98 | ||
|
|
f30f217023 | ||
|
|
9d3b037d4f | ||
|
|
c3515d74b9 | ||
|
|
b5192e577a | ||
|
|
9e2c75a8a3 | ||
|
|
7048cd5c86 | ||
|
|
9dbc75e78e | ||
|
|
81d973ffc4 | ||
|
|
3d8ef237e3 | ||
|
|
74d42cb3d0 | ||
|
|
1bca88bd30 | ||
|
|
1daa4587e5 | ||
|
|
88d44ab2c5 | ||
|
|
3ecf86933f | ||
|
|
1b350ac09e | ||
|
|
de9d796567 | ||
|
|
cd8e0a5e08 | ||
|
|
38f8179bc1 | ||
|
|
0b62e3bd7f | ||
|
|
f0370a3c20 | ||
|
|
e381117b1c | ||
|
|
f9791d7ab6 | ||
|
|
66a5bd8a88 | ||
|
|
de5fa4dc36 | ||
|
|
6dcf3ffd6a | ||
|
|
f139966554 | ||
|
|
b0c4cc99ed | ||
|
|
3e3e944cf4 | ||
|
|
f3b7dac768 | ||
|
|
b9f3e59d48 | ||
|
|
801140abb2 | ||
|
|
b345ef1242 | ||
|
|
f6c0b60b7c | ||
|
|
ffe4d4046c | ||
|
|
7f9582e197 | ||
|
|
6e8b7aaa07 | ||
|
|
31ae545289 | ||
|
|
71ce96b913 | ||
|
|
eba8de6ab5 | ||
|
|
4c730611e6 | ||
|
|
bf81496a4f | ||
|
|
6d29092fe6 | ||
|
|
b3ba1572b3 | ||
|
|
369adced52 | ||
|
|
b510244518 | ||
|
|
c44ad7ecd1 | ||
|
|
bd4889e243 | ||
|
|
3634f5e2d8 | ||
|
|
76ae8c19b0 | ||
|
|
91ef9301c2 | ||
|
|
510a299420 | ||
|
|
38d690e425 | ||
|
|
e9a746d4ea | ||
|
|
c0a0e3a1c6 | ||
|
|
3681fd132b | ||
|
|
bb18cf618a | ||
|
|
fbaa0caedb | ||
|
|
45f412456c | ||
|
|
9f67542bd5 | ||
|
|
b4fd965c43 | ||
|
|
b365d818f3 | ||
|
|
6f6c939624 | ||
|
|
0d98df4a00 | ||
|
|
5aecd36194 | ||
|
|
539441b3d5 | ||
|
|
4b9eff1759 | ||
|
|
48bc2f3166 | ||
|
|
14af053c66 | ||
|
|
4f3116d28a | ||
|
|
5666b3014a | ||
|
|
07ace19b69 | ||
|
|
f1a20a9fe9 | ||
|
|
eac5d93f78 | ||
|
|
541e4c04cf | ||
|
|
ce8ad8c68f | ||
|
|
1ede1bccf7 | ||
|
|
a5c2f39f65 | ||
|
|
a807eb0eb2 | ||
|
|
d406c29e16 | ||
|
|
8c6abcb41f | ||
|
|
b8ccd543c4 | ||
|
|
ae5dfc0b3c | ||
|
|
539d2969f1 | ||
|
|
7fb3dda20c | ||
|
|
89ec358d00 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
@@ -8,4 +8,4 @@ user-invocable: true
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
|
||||
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -6,6 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
|
||||
|
||||
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -20,6 +20,10 @@ env:
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# This job never executes Electron. Skipping the hosted binary avoids
|
||||
# unrelated npm ci failures when Electron's CDN returns 504.
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -36,6 +40,8 @@ jobs:
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -58,6 +64,8 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -88,6 +96,8 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -131,8 +141,35 @@ jobs:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install dependencies with Electron retry
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
npm ci
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
if ($attempt -eq 3) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
@@ -142,6 +179,8 @@ jobs:
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -164,6 +203,8 @@ jobs:
|
||||
|
||||
sdk-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -189,6 +230,8 @@ jobs:
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -229,6 +272,8 @@ jobs:
|
||||
|
||||
relay-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -253,6 +298,8 @@ jobs:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,6 +64,7 @@ CLAUDE.local.md
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.dev/
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -1,5 +1,136 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.93 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Fable 5 is available in the Claude model picker** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain))
|
||||
|
||||
## 0.1.92 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Skills autocomplete inside prompts**
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider catalog is inline in Host settings ([#1423](https://github.com/getpaseo/paseo/pull/1423))
|
||||
- Manual update checks skip staged rollout delays
|
||||
- CodeWhale replaces DeepSeek TUI in the provider catalog
|
||||
- ACP provider catalog entries are updated for Cline, Codebuddy Code, DimCode, Factory Droid, Gemini, Nova, and Qoder
|
||||
- OMP has its own icon and website page
|
||||
- Model selector descriptions are clearer
|
||||
- ACP provider errors show the provider's real failure message
|
||||
|
||||
### Fixed
|
||||
|
||||
- New Paseo worktree branches can push their first commits
|
||||
- Imported sessions no longer open blank or in the wrong workspace
|
||||
- Windows Explorer opens the selected workspace instead of Documents ([#1412](https://github.com/getpaseo/paseo/pull/1412) by [@bjspi](https://github.com/bjspi))
|
||||
- Windows editor shortcuts installed as command shims launch correctly ([#1387](https://github.com/getpaseo/paseo/pull/1387) by [@Peter7896](https://github.com/Peter7896))
|
||||
- ACP providers that cannot use MCP servers can start correctly
|
||||
- Removed hosts no longer leave host pages stuck connecting
|
||||
- File preview links open in your external browser
|
||||
- Chat stays pinned to the latest message while output streams
|
||||
- The mobile composer send button no longer shifts while typing
|
||||
|
||||
## 0.1.91 - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Open multiple desktop windows** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost))
|
||||
- **Open browser pop-ups and links inside workspace tabs** ([#1375](https://github.com/getpaseo/paseo/pull/1375))
|
||||
- **Use the command center from mobile**
|
||||
- **Add OMP as a provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388))
|
||||
|
||||
### Improved
|
||||
|
||||
- New workspaces remember your last provider, mode, and thinking choices
|
||||
- Git controls now default ready branches to pull requests and hide unavailable pull or push actions
|
||||
- Desktop-managed hosts recover more reliably after stale daemon state
|
||||
- Daemon status now explains authentication failures
|
||||
- Project search skips Python virtual environments ([#1356](https://github.com/getpaseo/paseo/pull/1356))
|
||||
- Config files can include `$schema` for editor help
|
||||
- Claude MCP servers preserve always-load tool settings ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain))
|
||||
- Claude profiles keep their configured models ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Provider loading can wait longer on slow machines ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain))
|
||||
- The Kimi catalog entry now points to Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000))
|
||||
- ACP provider catalog entries are updated for Auggie, Claude Agent, Cline, Codebuddy Code, DimCode, Factory Droid, fast-agent, Gemini, GitHub Copilot, and Nova
|
||||
- Local dictation crash reports show more useful details ([#1379](https://github.com/getpaseo/paseo/pull/1379))
|
||||
- Daemon logs show why managed workers exit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi compaction slash commands run correctly ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
|
||||
- Auto-archiving still works after a merged PR branch is deleted ([#1378](https://github.com/getpaseo/paseo/pull/1378))
|
||||
- Worktrees can check out existing branch refs correctly ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90))
|
||||
- File downloads work when daemon password protection is enabled ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain))
|
||||
- iOS markdown links are tappable again ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi))
|
||||
- iOS markdown images render correctly
|
||||
- Windows workspaces load their providers correctly ([#1329](https://github.com/getpaseo/paseo/pull/1329))
|
||||
- Removing a localhost host stops its local daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger))
|
||||
- Provider settings sheets stack correctly
|
||||
- The new workspace screen no longer opens behind the mobile sidebar
|
||||
- Global agent listing works again ([#1420](https://github.com/getpaseo/paseo/pull/1420))
|
||||
- OpenCode compaction summaries stay out of chat
|
||||
- OpenCode agents sharing a workspace keep their own Paseo tools
|
||||
|
||||
## 0.1.90 - 2026-06-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Group the sidebar by status so workspaces waiting on you, ready to review, working, and done are visible at a glance** ([#1317](https://github.com/getpaseo/paseo/pull/1317))
|
||||
- **Start a new workspace from the global sidebar button without choosing a project first** ([#1324](https://github.com/getpaseo/paseo/pull/1324))
|
||||
- **Open the active file directly in your editor, file manager, or GitHub instead of only opening the workspace root** ([#1285](https://github.com/getpaseo/paseo/pull/1285) by [@aaronzhongg](https://github.com/aaronzhongg))
|
||||
- **Automatically archive clean PR workspaces after the PR is merged from host settings** ([#1313](https://github.com/getpaseo/paseo/pull/1313))
|
||||
- **Desktop-managed Paseo skills stay current after installing a newer desktop build** ([#1309](https://github.com/getpaseo/paseo/pull/1309))
|
||||
- **Dart files and Dart code blocks are now syntax-highlighted** ([#1326](https://github.com/getpaseo/paseo/pull/1326))
|
||||
|
||||
### Improved
|
||||
|
||||
- Sidebar workspaces can be marked as read when they are ready to review or failed ([#1317](https://github.com/getpaseo/paseo/pull/1317))
|
||||
- Child agents keep unattended permissions when delegated across providers ([#1315](https://github.com/getpaseo/paseo/pull/1315))
|
||||
- Scheduled agents open with the real prompt and title instead of looking empty ([#1316](https://github.com/getpaseo/paseo/pull/1316))
|
||||
- Git controls prioritize the action that gets a ready branch shipped ([#1316](https://github.com/getpaseo/paseo/pull/1316))
|
||||
- Multiple agent questions are shown one at a time
|
||||
- OpenCode questions with free-write answers show the typed response in Paseo
|
||||
- Delegated agent activity is visible on the parent workspace
|
||||
- Sessions are ordered by latest activity
|
||||
- ACP provider catalog entries are updated for Claude Agent, Cline, Codebuddy Code, Factory Droid, and Qoder
|
||||
|
||||
### Fixed
|
||||
|
||||
- Timeline catch-up no longer leaves older messages unloaded
|
||||
- Markdown code in file previews renders correctly
|
||||
- Long dictation retries no longer stall new audio
|
||||
- Settings host picker navigation works from host settings pages
|
||||
- Diff gutter rows stay aligned with changed code
|
||||
- Mobile sidebar gestures stay responsive under load
|
||||
- Compact sheets keep their footer and bottom spacing visible
|
||||
|
||||
## 0.1.89 - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
|
||||
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
|
||||
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- **Delegated agents can run independently and send recurring heartbeat updates**
|
||||
|
||||
### Improved
|
||||
|
||||
- Composer controls fit better in narrow panes
|
||||
- Fork pull request badges stay visible in worktrees
|
||||
- Cline in the ACP catalog is updated to v3
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
|
||||
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
|
||||
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- Pi extension command output no longer hangs
|
||||
- Delegated agents no longer appear in workspace alert counts
|
||||
|
||||
## 0.1.88 - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
@@ -48,7 +49,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run dev # Start the dev daemon
|
||||
npm run dev:app # Start Expo against the dev daemon
|
||||
npm run dev:desktop # Start Electron desktop dev
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
@@ -57,6 +60,8 @@ npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
|
||||
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
|
||||
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -54,6 +54,12 @@ Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-onl
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
|
||||
## Workspace activity
|
||||
|
||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||
|
||||
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
@@ -77,12 +83,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
@@ -99,11 +99,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
@@ -232,7 +238,7 @@ initializing → idle ⇄ running
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
|
||||
@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -347,6 +347,41 @@ Override the command used to launch any provider with the `command` field. This
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
### Pi-compatible forks with their own session directory
|
||||
|
||||
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"omp": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-pi-fork": {
|
||||
"extends": "pi",
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
@@ -364,7 +399,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
|
||||
|
||||
---
|
||||
|
||||
@@ -374,7 +409,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
@@ -403,6 +438,25 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "acp"],
|
||||
"params": {
|
||||
"supportsMcpServers": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
@@ -539,18 +593,19 @@ When an `additionalModels` entry has the same `id` as a discovered model, it upd
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ |
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
@@ -577,7 +632,7 @@ Each entry in the `models` array:
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
@@ -604,7 +659,7 @@ Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ $PASEO_HOME/
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
app: {
|
||||
baseUrl: string
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
@@ -184,7 +187,7 @@ Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
|
||||
@@ -94,6 +94,8 @@ Five primitives. The pick is determined by option count, the need to search, and
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
@@ -8,18 +8,27 @@
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
```
|
||||
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
Root checkout dev is intentionally split across terminals:
|
||||
|
||||
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
|
||||
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
|
||||
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
### PASEO_HOME
|
||||
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -32,16 +41,19 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
### Daemon endpoints
|
||||
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- Root checkout dev daemon: `localhost:6768`.
|
||||
- Root checkout Expo: `http://localhost:8081`.
|
||||
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### React render profiling
|
||||
|
||||
@@ -158,12 +170,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
@@ -183,7 +197,7 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
|
||||
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
|
||||
|
||||
Use the named root build targets instead of remembering workspace dependency chains:
|
||||
|
||||
@@ -202,9 +216,26 @@ For tighter loops, you can rebuild a single workspace:
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
and `uvx`) to exact package versions. Run the drift checker regularly — and
|
||||
before releases — so catalog installs do not sit on stale agent versions:
|
||||
|
||||
```bash
|
||||
npm run acp:version-drift # report stale/non-exact package pins
|
||||
npm run acp:version-drift:check # same, exits non-zero on drift
|
||||
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
|
||||
```
|
||||
|
||||
The checker updates only package-runner catalog entries. Providers that use a
|
||||
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
|
||||
are reported as skipped because their versions are owned by the user's local
|
||||
install.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -258,7 +289,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
|
||||
@@ -169,6 +169,11 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
|
||||
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
|
||||
ignore ref churn while dismissing.
|
||||
|
||||
Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
@@ -15,10 +15,11 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
|
||||
@@ -70,8 +70,8 @@ Anyone who builds software:
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
|
||||
@@ -14,7 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
@@ -24,14 +24,20 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
@@ -260,7 +266,7 @@ case "my-provider":
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
@@ -311,7 +317,13 @@ interface AgentClient {
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession?(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -9,6 +9,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
@@ -30,7 +33,7 @@ Rules that apply to both steps:
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -63,14 +66,15 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
@@ -85,7 +89,7 @@ Use the beta path when you need to:
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
@@ -169,7 +173,7 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||
|
||||
## Mobile builds (EAS)
|
||||
|
||||
@@ -232,18 +236,16 @@ To confirm the submission landed, inspect the EAS workflow with `npx eas workflo
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
|
||||
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_schedule arguments
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"every": "15m",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"target": "self", // heartbeat, NOT "new-agent"
|
||||
"cwd": "/path/to/paseo",
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
}
|
||||
```
|
||||
@@ -302,6 +304,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
|
||||
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
@@ -422,6 +425,7 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
|
||||
92
docs/service-proxy.md
Normal file
92
docs/service-proxy.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Service Proxy
|
||||
|
||||
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
```
|
||||
<script>--<branch>--<project>.localhost
|
||||
```
|
||||
|
||||
If the branch is `main` or `master`, the branch segment is omitted:
|
||||
|
||||
```
|
||||
<script>--<project>.localhost
|
||||
```
|
||||
|
||||
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
|
||||
|
||||
```
|
||||
dev--feature-auth--miniweb.localhost
|
||||
```
|
||||
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"serviceProxy": {
|
||||
"listen": "0.0.0.0:8080",
|
||||
"publicBaseUrl": "https://paseoapps.my.domain.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
|
||||
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
|
||||
|
||||
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
|
||||
|
||||
## DNS and reverse proxy setup
|
||||
|
||||
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
|
||||
|
||||
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
|
||||
|
||||
1. Configure a wildcard DNS record:
|
||||
|
||||
```
|
||||
*.paseoapps.my.domain.com → 10.1.1.1
|
||||
```
|
||||
|
||||
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
|
||||
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
|
||||
@@ -88,6 +88,8 @@ Do not split a component into plain and Unistyles variants just to handle one hi
|
||||
|
||||
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-vz+c4jwakEd0nBtsR4mmBk8eq9WYVqqQxXOx2tHKSUA=
|
||||
sha256-YZNXy7OyeGAmQqbNObKeuKOpM+8ova8djd5BDBUH2+A=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36953,7 +36953,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37178,12 +37178,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/server": "0.1.88",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/server": "0.1.93",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37429,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37704,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37971,7 +37971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38210,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.88",
|
||||
"@getpaseo/highlight": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/client": "0.1.93",
|
||||
"@getpaseo/highlight": "0.1.93",
|
||||
"@getpaseo/protocol": "0.1.93",
|
||||
"@getpaseo/relay": "0.1.93",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +38989,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
37
package.json
37
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -34,22 +34,30 @@
|
||||
"packages/cli"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev": "npm run dev:server",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
|
||||
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
@@ -62,6 +70,9 @@
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
|
||||
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
|
||||
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
@@ -71,11 +82,11 @@
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
@@ -87,14 +98,16 @@
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:publish:beta && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.234"
|
||||
gem "multi_json"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
@@ -21,7 +21,7 @@ test.describe("ACP provider catalog", () => {
|
||||
await openSettingsHost(page, getServerId());
|
||||
// Providers moved to their own host section; add-provider lives there now.
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderModal(page);
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
|
||||
28
packages/app/e2e/agent-timeline-pagination.spec.ts
Normal file
28
packages/app/e2e/agent-timeline-pagination.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptVisible,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "./helpers/timeline-pagination";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page);
|
||||
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
505
packages/app/e2e/diff-row-alignment.spec.ts
Normal file
505
packages/app/e2e/diff-row-alignment.spec.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
|
||||
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set<string>([input.activeTabId]);
|
||||
}
|
||||
|
||||
function setsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
|
||||
const { activeTabId, allTabIds, cap } = input;
|
||||
const allTabIdsKey = allTabIds.join("\\u0000");
|
||||
const availableTabIds = useMemo(() => {
|
||||
void allTabIdsKey;
|
||||
return new Set(allTabIds);
|
||||
}, [allTabIds, allTabIdsKey]);
|
||||
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
|
||||
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
|
||||
if (activeTabId && availableTabIds.has(activeTabId)) {
|
||||
const existingIndex = nextLru.indexOf(activeTabId);
|
||||
if (existingIndex >= 0) {
|
||||
nextLru.splice(existingIndex, 1);
|
||||
}
|
||||
nextLru.unshift(activeTabId);
|
||||
}
|
||||
if (nextLru.length > cap) {
|
||||
nextLru.length = cap;
|
||||
}
|
||||
|
||||
lruRef.current = nextLru;
|
||||
setMountedTabIds((previousMountedTabIds) => {
|
||||
const nextMountedTabIds = new Set(nextLru);
|
||||
return setsEqual(previousMountedTabIds, nextMountedTabIds)
|
||||
? previousMountedTabIds
|
||||
: nextMountedTabIds;
|
||||
});
|
||||
}, [activeTabId, availableTabIds, cap]);
|
||||
|
||||
return { mountedTabIds };
|
||||
}
|
||||
`;
|
||||
|
||||
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
interface DeriveRenderMountedTabIdsInput {
|
||||
activeTabId: string | null;
|
||||
availableTabIds: Set<string>;
|
||||
cap: number;
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
|
||||
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set<string>([input.activeTabId]);
|
||||
}
|
||||
|
||||
function setsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
|
||||
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
|
||||
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
|
||||
return mountedTabIds;
|
||||
}
|
||||
|
||||
const next = new Set<string>([activeTabId]);
|
||||
const maxSize = Math.max(1, cap);
|
||||
for (const tabId of mountedTabIds) {
|
||||
if (next.size >= maxSize) {
|
||||
break;
|
||||
}
|
||||
if (availableTabIds.has(tabId)) {
|
||||
next.add(tabId);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
|
||||
const { activeTabId, allTabIds, cap } = input;
|
||||
const allTabIdsKey = allTabIds.join("\\u0000");
|
||||
const availableTabIds = useMemo(() => {
|
||||
void allTabIdsKey;
|
||||
return new Set(allTabIds);
|
||||
}, [allTabIds, allTabIdsKey]);
|
||||
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
|
||||
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
|
||||
const renderMountedTabIds = useMemo(
|
||||
() =>
|
||||
deriveRenderMountedTabIds({
|
||||
activeTabId,
|
||||
availableTabIds,
|
||||
cap,
|
||||
mountedTabIds,
|
||||
}),
|
||||
[activeTabId, availableTabIds, cap, mountedTabIds],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
|
||||
if (activeTabId && availableTabIds.has(activeTabId)) {
|
||||
const existingIndex = nextLru.indexOf(activeTabId);
|
||||
if (existingIndex >= 0) {
|
||||
nextLru.splice(existingIndex, 1);
|
||||
}
|
||||
nextLru.unshift(activeTabId);
|
||||
}
|
||||
if (nextLru.length > cap) {
|
||||
nextLru.length = cap;
|
||||
}
|
||||
|
||||
lruRef.current = nextLru;
|
||||
setMountedTabIds((previousMountedTabIds) => {
|
||||
const nextMountedTabIds = new Set(nextLru);
|
||||
return setsEqual(previousMountedTabIds, nextMountedTabIds)
|
||||
? previousMountedTabIds
|
||||
: nextMountedTabIds;
|
||||
});
|
||||
}, [activeTabId, availableTabIds, cap]);
|
||||
|
||||
return { mountedTabIds: renderMountedTabIds };
|
||||
}
|
||||
`;
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task.run();
|
||||
}
|
||||
});
|
||||
|
||||
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 9);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectDiffCodeFontSize(page, 9);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
await expectDiffCodeTextAlignedWithGutterText(page, [
|
||||
{
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
},
|
||||
{ codeText: "return next;", lineNumber: "55" },
|
||||
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
|
||||
]);
|
||||
await expectHoverCommentButtonAlignedWithCodeLine(page, {
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
});
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await returnToWorkspaceChanges(page);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiFontSize: 16,
|
||||
codeFontSize: fontSize,
|
||||
syntaxTheme: "one",
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
|
||||
);
|
||||
}
|
||||
|
||||
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const actualFontSize = await page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
expect(actualFontSize).toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
|
||||
}
|
||||
|
||||
async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
maxDelta: number;
|
||||
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
|
||||
rows: {
|
||||
index: number;
|
||||
gutterTop: number;
|
||||
codeTop: number;
|
||||
delta: number;
|
||||
gutterLineHeight: number;
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
}> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||
if (!root) {
|
||||
throw new Error("Expanded diff body is not mounted");
|
||||
}
|
||||
|
||||
const readRows = (prefix: string, textPrefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||
const testId = row.getAttribute("data-testid") ?? "";
|
||||
const index = Number(testId.slice(prefix.length));
|
||||
const rect = row.getBoundingClientRect();
|
||||
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||
});
|
||||
|
||||
const gutters = new Map(
|
||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
||||
);
|
||||
const codes = readRows("diff-code-row-", "diff-code-text-");
|
||||
const rows = codes
|
||||
.map((code) => {
|
||||
const gutter = gutters.get(code.index);
|
||||
if (!gutter) {
|
||||
throw new Error(`Missing gutter row ${code.index}`);
|
||||
}
|
||||
return {
|
||||
index: code.index,
|
||||
gutterTop: gutter.top,
|
||||
codeTop: code.top,
|
||||
delta: Math.abs(code.top - gutter.top),
|
||||
gutterLineHeight: gutter.lineHeight,
|
||||
codeLineHeight: code.lineHeight,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
|
||||
|
||||
return {
|
||||
maxDelta: Math.max(...rows.map((row) => row.delta)),
|
||||
mismatchedTypography: rows
|
||||
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
|
||||
.map((row) => ({
|
||||
index: row.index,
|
||||
gutterLineHeight: row.gutterLineHeight,
|
||||
codeLineHeight: row.codeLineHeight,
|
||||
})),
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
return { id: opened.workspace.id };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await page.getByTestId("diff-file-0").click();
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
||||
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No unwrapped code rows are mounted");
|
||||
}
|
||||
return Math.max(
|
||||
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
|
||||
);
|
||||
});
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDiffCodeTextAlignedWithGutterText(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<void> {
|
||||
const geometries = await readDiffTextGeometry(page, lines);
|
||||
for (const geometry of geometries) {
|
||||
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHoverCommentButtonAlignedWithCodeLine(
|
||||
page: Page,
|
||||
line: { codeText: string; lineNumber: string },
|
||||
): Promise<void> {
|
||||
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
|
||||
if (!target) {
|
||||
throw new Error(`Could not find target line ${line.lineNumber}`);
|
||||
}
|
||||
await page.getByTestId(`diff-code-row-${target.index}`).hover();
|
||||
const geometry = await page
|
||||
.getByTestId(`diff-gutter-action-${target.index}`)
|
||||
.evaluate((action, expectedCodeCenterY) => {
|
||||
const rect = action.getBoundingClientRect();
|
||||
return {
|
||||
actionCenterY: rect.top + rect.height / 2,
|
||||
codeCenterY: expectedCodeCenterY,
|
||||
};
|
||||
}, target.codeCenterY);
|
||||
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
|
||||
}
|
||||
|
||||
async function readDiffTextGeometry(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<
|
||||
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
|
||||
> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
|
||||
if (!root) {
|
||||
throw new Error("Changes panel is not mounted");
|
||||
}
|
||||
|
||||
const readIndexedElements = (prefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
|
||||
(element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return { index: Number(testId.slice(prefix.length)), element };
|
||||
},
|
||||
);
|
||||
|
||||
const gutterTexts = readIndexedElements("diff-gutter-text-");
|
||||
const codeTexts = readIndexedElements("diff-code-text-");
|
||||
|
||||
return targets.map((target) => {
|
||||
const gutter = gutterTexts.find(
|
||||
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
|
||||
);
|
||||
if (!gutter) {
|
||||
throw new Error(`Could not find gutter line ${target.lineNumber}`);
|
||||
}
|
||||
const code = codeTexts.find(
|
||||
({ index, element }) =>
|
||||
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
|
||||
);
|
||||
if (!code) {
|
||||
throw new Error(`Could not find code row ${target.codeText}`);
|
||||
}
|
||||
|
||||
const codeRect = code.element.getBoundingClientRect();
|
||||
const gutterRect = gutter.element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
index: gutter.index,
|
||||
codeText: target.codeText,
|
||||
codeTop: codeRect.top,
|
||||
gutterTop: gutterRect.top,
|
||||
codeCenterY: codeRect.top + codeRect.height / 2,
|
||||
};
|
||||
});
|
||||
}, lines);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,6 +7,8 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
|
||||
interface WaitForServerOptions {
|
||||
@@ -188,7 +191,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeToolBinDir: string | null = null;
|
||||
let fakeEditorBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -209,81 +212,24 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
interface PairingDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonPairingOffer(): Promise<{
|
||||
relayEnabled: boolean;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -343,29 +289,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
clientId: `playwright-global-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
const pairing = await client.getDaemonPairingOffer();
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
throw new Error("Daemon returned a disabled pairing offer");
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(pairing.url);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
async function waitForPairingOfferFromDaemon(args: {
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
@@ -374,7 +320,7 @@ async function waitForPairingOfferFromCli(args: {
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
return await loadPairingOfferFromDaemon(args.port);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
@@ -382,7 +328,7 @@ async function waitForPairingOfferFromCli(args: {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
`Timed out waiting for daemon pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
@@ -629,7 +575,7 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeToolBinDir: string;
|
||||
fakeEditorBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
@@ -644,7 +590,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
@@ -710,9 +656,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
if (fakeEditorBinDir) {
|
||||
await rm(fakeEditorBinDir, { recursive: true, force: true });
|
||||
fakeEditorBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +673,7 @@ export default async function globalSetup() {
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
fakeEditorBinDir = await createFakeEditorBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -745,7 +691,7 @@ export default async function globalSetup() {
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeToolBinDir,
|
||||
fakeEditorBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
@@ -765,9 +711,8 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
@@ -64,6 +65,21 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
|
||||
interface DesktopEditorTargetConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
}
|
||||
|
||||
interface DesktopEditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
@@ -74,6 +90,7 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +104,15 @@ declare global {
|
||||
* can assert dialog copy without depending on window.confirm concatenation.
|
||||
*/
|
||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||
if (config.editorRecordPath) {
|
||||
await page.exposeFunction(
|
||||
"__recordDesktopEditorOpen",
|
||||
async (input: DesktopEditorOpenRecord) => {
|
||||
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await page.addInitScript((cfg) => {
|
||||
// Mutable state shared across IPC calls within this page
|
||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||
@@ -98,7 +124,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: null,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
@@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
editor?: {
|
||||
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
|
||||
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
};
|
||||
} = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "check_app_update") {
|
||||
@@ -202,15 +240,26 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
|
||||
if (cfg.editorTargets) {
|
||||
desktopBridge.editor = {
|
||||
listTargets: async () => cfg.editorTargets ?? [],
|
||||
openTarget: async (input: DesktopEditorOpenRecord) => {
|
||||
await window.__recordDesktopEditorOpen?.(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
// The daemon-lifecycle card moved to the Daemon section in the flat-settings
|
||||
// The daemon-lifecycle card moved to the Host section in the flat-settings
|
||||
// layout; navigate there before asserting it.
|
||||
await openSettingsHostSection(page, serverId, "daemon");
|
||||
await openSettingsHostSection(page, serverId, "host");
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
@@ -124,14 +124,30 @@ export async function openNewWorkspaceComposer(
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
projectDisplayName: string,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectPicker).toContainText(projectDisplayName);
|
||||
}
|
||||
|
||||
export async function submitNewWorkspacePrompt(
|
||||
page: Page,
|
||||
prompt = "Hello from e2e",
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(input.prompt ?? "Hello from e2e");
|
||||
await composer.fill(prompt);
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
@@ -139,6 +155,14 @@ export async function clickNewWorkspaceButton(
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
await submitNewWorkspacePrompt(page, input.prompt);
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
@@ -177,7 +201,9 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
@@ -212,7 +238,13 @@ export async function expectComposerGithubAttachmentPill(
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||
input: {
|
||||
serverId: string;
|
||||
previousWorkspaceId: string;
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
): Promise<{ workspaceId: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
@@ -230,15 +262,19 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
if (input.assertSidebarRow !== false) {
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
if (input.assertHeader !== false) {
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
61
packages/app/e2e/helpers/questions.ts
Normal file
61
packages/app/e2e/helpers/questions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectCurrentQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number; question: string },
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
page: Page,
|
||||
input: { question: string; answer: string },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("textbox", { name: input.question })
|
||||
.fill(input.answer);
|
||||
}
|
||||
|
||||
export async function submitQuestionAnswers(page: Page): Promise<void> {
|
||||
await page.getByTestId("question-form-primary-action").click();
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
@@ -43,6 +43,18 @@ export interface SeedDaemonClient {
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
providers: string[];
|
||||
limit: number;
|
||||
}): Promise<{
|
||||
entries: Array<{
|
||||
providerId: string;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
firstPromptPreview?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
|
||||
interface SavedSettingsHostInput {
|
||||
serverId: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
@@ -15,7 +25,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -84,6 +94,60 @@ export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function seedSavedSettingsHosts(
|
||||
page: Page,
|
||||
hosts: SavedSettingsHostInput[],
|
||||
): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const registry = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: host.endpoint,
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
const firstHost = registry[0];
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one settings host fixture.");
|
||||
}
|
||||
const preferences = buildCreateAgentPreferences(firstHost.serverId);
|
||||
|
||||
await page.evaluate(
|
||||
({ keys, storedRegistry, storedPreferences }) => {
|
||||
const nonce = localStorage.getItem(keys.seedNonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected e2e seed nonce before overriding settings host registry.");
|
||||
}
|
||||
|
||||
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
||||
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
registry: REGISTRY_KEY,
|
||||
seedNonce: SEED_NONCE_KEY,
|
||||
},
|
||||
storedRegistry: registry,
|
||||
storedPreferences: preferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("settings-host-picker").getByText(label, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
@@ -116,6 +180,18 @@ export async function goBackInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function closeCompactSettings(page: Page): Promise<void> {
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
}
|
||||
|
||||
export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -231,9 +307,9 @@ export async function openHostSection(
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
|
||||
// Restart + remove cards live on the Daemon section; providers moved to its
|
||||
// Restart + remove cards live on the Host section; providers moved to its
|
||||
// own Providers section (asserted via expectHostProvidersCard).
|
||||
await openSettingsHostSection(page, serverId, "daemon");
|
||||
await openSettingsHostSection(page, serverId, "host");
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
@@ -255,8 +331,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add provider", exact: true }).click();
|
||||
export async function openAddProviderArea(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -268,7 +344,6 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
@@ -296,9 +371,10 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
|
||||
|
||||
102
packages/app/e2e/helpers/timeline-pagination.ts
Normal file
102
packages/app/e2e/helpers/timeline-pagination.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
|
||||
function promptForTurn(index: number): string {
|
||||
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
||||
}
|
||||
|
||||
export async function seedLongMockAgentTimeline(
|
||||
options: LongTimelineAgentOptions,
|
||||
): Promise<LongTimelineAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-pagination-",
|
||||
title: "Timeline pagination regression",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
for (let index = 0; index < options.turns; index += 1) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
await page.mouse.wheel(0, -20_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const previousHeight = await scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight;
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight;
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(previousHeight);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
}
|
||||
230
packages/app/e2e/import-session.opencode.real.spec.ts
Normal file
230
packages/app/e2e/import-session.opencode.real.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const OPENCODE_SEED_TIMEOUT_MS = 45_000;
|
||||
const PASEO_REPO_PATH = path.resolve(__dirname, "../../..");
|
||||
|
||||
interface OpenCodeSeedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface ImportableOpenCodeSession {
|
||||
providerHandleId: string;
|
||||
}
|
||||
|
||||
interface OpenCodeImportScenario {
|
||||
workspace: SeededWorkspace;
|
||||
prompt: string;
|
||||
promptPreview: string;
|
||||
response: string;
|
||||
}
|
||||
|
||||
let workspace: SeededWorkspace | null = null;
|
||||
|
||||
test.setTimeout(150_000);
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup().catch(() => undefined);
|
||||
workspace = null;
|
||||
});
|
||||
|
||||
test("imports a real OpenCode session from the workspace import sheet", async ({ page }) => {
|
||||
const scenario = await seedPaseoWorkspaceWithOpenCodeSession();
|
||||
workspace = scenario.workspace;
|
||||
const importableSession = await waitForImportableOpenCodeSession(scenario);
|
||||
await openWorkspace(page, scenario.workspace);
|
||||
|
||||
await importOpenCodeSession(page, importableSession);
|
||||
|
||||
await expectImportSheetClosed(page);
|
||||
await expectImportedSessionOpen(page, scenario);
|
||||
});
|
||||
|
||||
async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportScenario> {
|
||||
const response = `PASEO_OPENCODE_IMPORT_E2E_OK_${randomUUID().slice(0, 8)}`;
|
||||
const prompt = `Do not use tools. Reply with exactly: ${response}`;
|
||||
const promptPreview = JSON.stringify(prompt);
|
||||
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(PASEO_REPO_PATH);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
promptPreview,
|
||||
response,
|
||||
workspace: {
|
||||
client,
|
||||
repoPath: PASEO_REPO_PATH,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function launchOpenCodeSessionInWorkspace(repoPath: string, prompt: string): Promise<void> {
|
||||
const result = await runOpenCodeSeed(repoPath, prompt);
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(formatOpenCodeLaunchError(result, prompt));
|
||||
}
|
||||
}
|
||||
|
||||
function openCodeSeedArgs(repoPath: string, prompt: string): string[] {
|
||||
return [
|
||||
"run",
|
||||
"--print-logs",
|
||||
"--log-level",
|
||||
"INFO",
|
||||
"--dir",
|
||||
repoPath,
|
||||
"--model",
|
||||
OPENCODE_REAL_MODEL,
|
||||
"--format",
|
||||
"json",
|
||||
prompt,
|
||||
];
|
||||
}
|
||||
|
||||
function runOpenCodeSeed(repoPath: string, prompt: string): Promise<OpenCodeSeedResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("opencode", openCodeSeedArgs(repoPath, prompt), {
|
||||
cwd: repoPath,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, OPENCODE_SEED_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stdout, stderr, code, signal, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatOpenCodeLaunchError(result: OpenCodeSeedResult, prompt: string): string {
|
||||
return [
|
||||
"OpenCode launch failed",
|
||||
`command: ${["opencode", ...openCodeSeedArgs(PASEO_REPO_PATH, prompt)].join(" ")}`,
|
||||
`exit: ${result.code ?? "null"}`,
|
||||
result.signal ? `signal: ${result.signal}` : null,
|
||||
result.timedOut ? `timed out after ${OPENCODE_SEED_TIMEOUT_MS}ms` : null,
|
||||
result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null,
|
||||
result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null,
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, seed: SeededWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), seed.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function waitForImportableOpenCodeSession(
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<ImportableOpenCodeSession> {
|
||||
let importableSession: ImportableOpenCodeSession | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
importableSession = await findImportableOpenCodeSession(scenario);
|
||||
return importableSession?.providerHandleId ?? "";
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1_000],
|
||||
},
|
||||
)
|
||||
.not.toBe("");
|
||||
return importableSession!;
|
||||
}
|
||||
|
||||
async function findImportableOpenCodeSession(
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<ImportableOpenCodeSession | null> {
|
||||
const sessions = await scenario.workspace.client.fetchRecentProviderSessions({
|
||||
cwd: scenario.workspace.repoPath,
|
||||
providers: ["opencode"],
|
||||
limit: 5,
|
||||
});
|
||||
const entry = sessions.entries.find(
|
||||
(session) =>
|
||||
session.providerId === "opencode" && session.firstPromptPreview === scenario.promptPreview,
|
||||
);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return { providerHandleId: entry.providerHandleId };
|
||||
}
|
||||
|
||||
async function importOpenCodeSession(
|
||||
page: Page,
|
||||
session: ImportableOpenCodeSession,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Workspace actions" }).click();
|
||||
await page.getByTestId("workspace-header-import-agent").click();
|
||||
await expect(page.getByTestId("import-session-sheet")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const importSheet = page.getByTestId("import-session-sheet");
|
||||
const sessionRow = importSheet.getByTestId(
|
||||
`import-session-session-opencode-${session.providerHandleId}`,
|
||||
);
|
||||
await expect(sessionRow).toBeVisible({ timeout: 60_000 });
|
||||
await sessionRow.click();
|
||||
}
|
||||
|
||||
async function expectImportSheetClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectImportedSessionOpen(
|
||||
page: Page,
|
||||
scenario: OpenCodeImportScenario,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid="user-message"]', { hasText: scenario.promptPreview }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="assistant-message"]', { hasText: scenario.response }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -7,16 +7,17 @@ import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
closeBranchPicker,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectPickerClosed,
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
@@ -24,8 +25,10 @@ import {
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
@@ -36,6 +39,147 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface WorkspaceStatusGroupEvent {
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
indicatorTestId: string | null;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function startTrackingSidebarStatusGroups(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => {
|
||||
interface StatusGroupEvent {
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
indicatorTestId: string | null;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: StatusGroupEvent[];
|
||||
__workspaceStatusGroupObserver?: MutationObserver;
|
||||
};
|
||||
win.__workspaceStatusGroupEvents = [];
|
||||
win.__workspaceStatusGroupObserver?.disconnect();
|
||||
|
||||
const capture = () => {
|
||||
const events = win.__workspaceStatusGroupEvents;
|
||||
if (!events) return;
|
||||
const groups = document.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="sidebar-status-group-"]',
|
||||
);
|
||||
for (const group of groups) {
|
||||
const groupTestId = group.getAttribute("data-testid") ?? "";
|
||||
const bucket = groupTestId.replace("sidebar-status-group-", "");
|
||||
const label = group.textContent ?? "";
|
||||
const block = group.parentElement?.parentElement;
|
||||
if (!block) continue;
|
||||
const rows = block.querySelectorAll<HTMLElement>('[data-testid^="sidebar-workspace-row-"]');
|
||||
for (const row of rows) {
|
||||
const rowTestId = row.getAttribute("data-testid");
|
||||
if (!rowTestId) continue;
|
||||
const indicatorTestId =
|
||||
row
|
||||
.querySelector<HTMLElement>('[data-testid^="workspace-status-indicator-"]')
|
||||
?.getAttribute("data-testid") ?? null;
|
||||
const last = events.at(-1);
|
||||
if (
|
||||
last?.rowTestId === rowTestId &&
|
||||
last.bucket === bucket &&
|
||||
last.indicatorTestId === indicatorTestId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
events.push({ rowTestId, bucket, indicatorTestId, label, at: performance.now() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
capture();
|
||||
const observer = new MutationObserver(capture);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
win.__workspaceStatusGroupObserver = observer;
|
||||
});
|
||||
}
|
||||
|
||||
async function getTrackedSidebarStatusGroups(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<WorkspaceStatusGroupEvent[]> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
|
||||
};
|
||||
return win.__workspaceStatusGroupEvents ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWorkspaceStatusGroupEvent(input: {
|
||||
page: import("@playwright/test").Page;
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
}) {
|
||||
await input.page.waitForFunction(
|
||||
({ expectedRowTestId, expectedBucket }) => {
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
|
||||
};
|
||||
for (const event of win.__workspaceStatusGroupEvents ?? []) {
|
||||
if (event.rowTestId === expectedRowTestId && event.bucket === expectedBucket) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ expectedRowTestId: input.rowTestId, expectedBucket: input.bucket },
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectWorkspaceStatusGroupEvents(input: {
|
||||
page: import("@playwright/test").Page;
|
||||
rowTestId: string;
|
||||
includes: string;
|
||||
excludes: string;
|
||||
includesIndicator?: string;
|
||||
excludesIndicator?: string;
|
||||
}) {
|
||||
await waitForWorkspaceStatusGroupEvent({
|
||||
page: input.page,
|
||||
rowTestId: input.rowTestId,
|
||||
bucket: input.includes,
|
||||
});
|
||||
const createdWorkspaceEvents = (await getTrackedSidebarStatusGroups(input.page)).filter(
|
||||
(event) => event.rowTestId === input.rowTestId,
|
||||
);
|
||||
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
|
||||
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
|
||||
if (input.includesIndicator) {
|
||||
expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain(
|
||||
input.includesIndicator,
|
||||
);
|
||||
}
|
||||
if (input.excludesIndicator) {
|
||||
expect(
|
||||
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").Page) {
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
@@ -193,7 +337,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||
test("global new workspace uses the last active project and creates one agent tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -217,15 +361,16 @@ test.describe("New workspace flow", () => {
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await clickNewWorkspaceButton(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspacePrompt(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
@@ -312,6 +457,8 @@ test.describe("New workspace flow", () => {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
@@ -343,6 +490,117 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("new workspace with initial agent never appears in the Done status group", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-status-optimistic-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await startTrackingSidebarStatusGroups(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspacePrompt(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "running",
|
||||
excludes: "done",
|
||||
includesIndicator: "workspace-status-indicator-running",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("new workspace without an initial agent appears in the Done status group", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-status-empty-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await startTrackingSidebarStatusGroups(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "done",
|
||||
excludes: "running",
|
||||
excludesIndicator: "workspace-status-indicator-loading",
|
||||
});
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "done",
|
||||
excludes: "running",
|
||||
excludesIndicator: "workspace-status-indicator-running",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -400,7 +658,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
@@ -450,10 +708,16 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "new-workspace-pr-ref",
|
||||
prs: [{ title: "Review selected start ref", state: "open" }],
|
||||
});
|
||||
const pr = ghRepo.prs[0]!;
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
const openedProject = await openProjectViaDaemon(client, pr.localPath);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -463,19 +727,19 @@ test.describe("New workspace flow", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
109
packages/app/e2e/provider-settings-refresh.spec.ts
Normal file
109
packages/app/e2e/provider-settings-refresh.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-sheet-stack-",
|
||||
title: "Provider sheet stack e2e",
|
||||
initialPrompt: "Prepare provider sheet stack test agent.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async function openProviderSettingsFromModelSelector(page: Page) {
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
|
||||
if (await openCodeRow.isVisible().catch(() => false)) {
|
||||
await openCodeRow.click();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectModelSelectorVisible(page: Page) {
|
||||
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeTopSheet(page: Page) {
|
||||
const closeTarget = page.getByLabel("Close", { exact: true }).last();
|
||||
if (await closeTarget.isVisible().catch(() => false)) {
|
||||
await closeTarget.click({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = page.getByRole("slider", { name: "Bottom sheet handle" }).last();
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error("Bottom sheet handle was not measurable");
|
||||
}
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY + 400, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add model" }).click();
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
}
|
||||
|
||||
test.describe("provider settings bottom-sheet stack", () => {
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await closeTopSheet(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
packages/app/e2e/question-prompt-pagination.spec.ts
Normal file
73
packages/app/e2e/question-prompt-pagination.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test } from "./fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
expectCurrentQuestion,
|
||||
expectQuestionHidden,
|
||||
expectQuestionOptionSelected,
|
||||
fillQuestionAnswer,
|
||||
openQuestion,
|
||||
submitQuestionAnswers,
|
||||
waitForQuestionPrompt,
|
||||
} from "./helpers/questions";
|
||||
|
||||
const TOTAL_QUESTIONS = 3;
|
||||
const SURFACE_QUESTION = "Which surface should this apply to?";
|
||||
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-pagination-",
|
||||
title: "Question pagination e2e",
|
||||
initialPrompt: "Emit synthetic questions.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 1,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SURFACE_QUESTION,
|
||||
});
|
||||
await expectQuestionHidden(page, ROLLOUT_QUESTION);
|
||||
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||
|
||||
await chooseQuestionOption(page, "App");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 2,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: ROLLOUT_QUESTION,
|
||||
});
|
||||
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 1,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SURFACE_QUESTION,
|
||||
});
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SUCCESS_QUESTION,
|
||||
});
|
||||
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Only one prompt is visible at a time.",
|
||||
});
|
||||
await submitQuestionAnswers(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,15 +33,15 @@ test.describe("Settings host page", () => {
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
|
||||
test("orchestration section shows the inject MCP toggle", async ({ page }) => {
|
||||
test("agents section shows the inject MCP toggle", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "orchestration");
|
||||
await expectSettingsHeader(page, "Orchestration");
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
|
||||
@@ -56,15 +56,15 @@ test.describe("Settings host page", () => {
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
|
||||
test("daemon section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await expectSettingsHeader(page, "Daemon");
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
@@ -75,14 +75,14 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -90,7 +90,7 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
@@ -113,7 +113,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
@@ -26,7 +29,21 @@ import {
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
expectAppearanceContent,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
removeCurrentHostFromSettings,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: { workspaceId: string },
|
||||
) {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
@@ -157,4 +174,44 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
|
||||
test("switching the host picker on the settings list scopes host rows without navigating", async ({
|
||||
page,
|
||||
}) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const secondaryHostLabel = "Stable horse";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "host");
|
||||
await removeCurrentHostFromSettings(page);
|
||||
await closeCompactSettings(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/welcome$/);
|
||||
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
command: string;
|
||||
args: string[];
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
@@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void>
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
command: string;
|
||||
workspacePath: string;
|
||||
editorId: string;
|
||||
path: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
@@ -53,10 +55,7 @@ async function expectEditorOpened(input: {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some(
|
||||
(record) =>
|
||||
record.command === input.command && record.args.includes(input.workspacePath),
|
||||
);
|
||||
.some((record) => record.editorId === input.editorId && record.path === input.path);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => {
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await injectDesktopBridge(page, { serverId });
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{ id: "cursor", label: "Cursor", kind: "editor" },
|
||||
{ id: "vscode", label: "VS Code", kind: "editor" },
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
@@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
@@ -94,8 +100,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
@@ -105,8 +111,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.93",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,7 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -17,14 +17,18 @@
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
/* Keep the app shell fixed to the viewport. */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* These styles smooth text rendering in the app shell. */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -38,8 +42,12 @@
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
@@ -560,6 +560,95 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("keeps initial native content growth anchored before layout scroll events arrive", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "native-virtualized",
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
contentHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
harness.context.measurementState.contentHeight = 1348;
|
||||
harness.context.measurementState.contentMeasuredForKey = "native-virtualized";
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 0,
|
||||
contentHeight: 1348,
|
||||
});
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
requestId: null,
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.viewportWidth = 390;
|
||||
harness.context.measurementState.viewportHeight = 546;
|
||||
harness.context.measurementState.viewportMeasuredForKey = "native-virtualized";
|
||||
harness.context.measurementState.offsetY = 50;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 50,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
it("keeps native sticky content changes anchored when measured height is unchanged", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "native-virtualized",
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 546,
|
||||
contentHeight: 546,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: "native-virtualized",
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
requestId: null,
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.offsetY = 50;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 50,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller helper predicates", () => {
|
||||
|
||||
@@ -244,29 +244,6 @@ function createBottomAnchorControllerDriver(
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
|
||||
const _getLogContext = (extra?: Record<string, unknown>) => {
|
||||
const measurementState = input.getMeasurementState();
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
|
||||
);
|
||||
return {
|
||||
agentId: input.getAgentId(),
|
||||
requestReason: pendingRequest?.reason ?? null,
|
||||
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
contentHeight: measurementState.contentHeight,
|
||||
viewportHeight: measurementState.viewportHeight,
|
||||
offset: measurementState.offsetY,
|
||||
distanceFromBottom,
|
||||
renderStrategy: input.getRenderStrategy(),
|
||||
blockedReason,
|
||||
mode,
|
||||
containerKey: measurementState.containerKey,
|
||||
transportBehavior: input.getTransportBehavior(),
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
return;
|
||||
@@ -327,7 +304,6 @@ function createBottomAnchorControllerDriver(
|
||||
});
|
||||
|
||||
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
|
||||
const _scheduledMeasurementState = input.getMeasurementState();
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
}
|
||||
@@ -560,6 +536,12 @@ function createBottomAnchorControllerDriver(
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
runAttempt(false);
|
||||
return;
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false, "content_size_change");
|
||||
@@ -576,6 +558,16 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
runAttempt(false);
|
||||
return;
|
||||
}
|
||||
evaluate(false, "content_size_change");
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
@@ -656,11 +648,7 @@ export const __private__ = {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
input.previousContentHeight > 0 &&
|
||||
input.contentHeight > input.previousContentHeight
|
||||
);
|
||||
return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight;
|
||||
},
|
||||
shouldDetachFromScrollAway(input: {
|
||||
mode: BottomAnchorMode;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
@@ -81,7 +84,6 @@ import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
mapPathnameToServer,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
@@ -493,7 +495,6 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
@@ -503,12 +504,13 @@ function MobileGestureWrapper({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
const openGestureEnabled = chromeEnabled;
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
@@ -538,6 +540,11 @@ function MobileGestureWrapper({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
@@ -579,9 +586,11 @@ function MobileGestureWrapper({
|
||||
isGesturing.value = false;
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
}
|
||||
})
|
||||
@@ -593,6 +602,7 @@ function MobileGestureWrapper({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleGestureOpen,
|
||||
@@ -800,7 +810,6 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
@@ -809,16 +818,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const shouldShowAppChrome =
|
||||
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId || hosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
@@ -921,17 +920,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// PortalProvider must remain the innermost global provider here.
|
||||
// PortalProvider must stay inside normal app-wide context providers here.
|
||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
||||
// Adding a new global provider? Put it above PortalProvider.
|
||||
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</QueryProvider>
|
||||
|
||||
25
packages/app/src/app/h/[serverId]/_layout.tsx
Normal file
25
packages/app/src/app/h/[serverId]/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveKnownHostRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<KnownHostRoute />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const resolution = resolveKnownHostRoute({ routeServerId, hosts });
|
||||
|
||||
if (resolution.kind === "redirect") {
|
||||
return <Redirect href={resolution.href} />;
|
||||
}
|
||||
|
||||
return <Slot />;
|
||||
}
|
||||
@@ -9,14 +9,10 @@ export default function HostNewWorkspaceRoute() {
|
||||
projectId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
|
||||
import { normalizeHostSectionSlug } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostSectionRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
|
||||
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
|
||||
const section = normalizeHostSectionSlug(rawSection) ?? "connections";
|
||||
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||
deepagents:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||
"deepseek-tui":
|
||||
codewhale:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||
dimcode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||
|
||||
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 397 B |
@@ -3,6 +3,7 @@ export {
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
|
||||
24
packages/app/src/assistant-file-links/link-press-context.ts
Normal file
24
packages/app/src/assistant-file-links/link-press-context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
|
||||
// Carries a link's press handler down to the leaf text spans that render its
|
||||
// label. On iOS an assistant link is a nested UITextView span, and
|
||||
// react-native-uitextview only attaches onPress to the *string* children it
|
||||
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
|
||||
// MarkdownInheritedText spans markdown produces for link text) pass through
|
||||
// untouched, so an onPress placed on the wrapping span never reaches a tappable
|
||||
// native node. Threading the handler through context lets each leaf span hand
|
||||
// onPress to its own string children, where the native tap recognizer can find
|
||||
// it. Provided only on iOS (Android/web links tap fine via their own paths).
|
||||
export interface AssistantLinkPress {
|
||||
onPress: () => void;
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
|
||||
|
||||
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
|
||||
|
||||
export function useAssistantLinkPress(): AssistantLinkPress | null {
|
||||
return useContext(AssistantLinkPressContext);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -56,23 +58,42 @@ export function AssistantMarkdownLink({
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
() => ({ onPress, accessibilityRole: "link" }),
|
||||
[onPress],
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
||||
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||
// (the link disappears). The span composes correctly and stays selectable;
|
||||
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
|
||||
// (the link disappears). The span composes correctly and stays selectable.
|
||||
//
|
||||
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
||||
// children it turns into RNUITextViewChild nodes — the element children that
|
||||
// markdown emits for link text pass through untouched, so an onPress placed
|
||||
// here never reaches a tappable native node. We thread it down through
|
||||
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
||||
// own string children, where the native tap recognizer can find it. iOS
|
||||
// only: Android forwards onPress through nested <Text> already, and web uses
|
||||
// the <a> path below.
|
||||
const span = (
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
{Platform.OS === "ios" ? (
|
||||
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
54
packages/app/src/browser/new-tab-requests/index.test.ts
Normal file
54
packages/app/src/browser/new-tab-requests/index.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { createDefaultLayout } from "@/stores/workspace-layout-store";
|
||||
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
|
||||
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
|
||||
|
||||
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
||||
return openTabInLayoutFocused({
|
||||
layout: createDefaultLayout(),
|
||||
target: { kind: "browser", browserId },
|
||||
now: 1,
|
||||
}).layout;
|
||||
}
|
||||
|
||||
describe("browser new-tab requests", () => {
|
||||
it("accepts desktop requests from browser tabs in the current workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toEqual<BrowserNewTabRequest>({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores desktop requests from another workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-from-other-workspace",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsupported desktop request URLs", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "file:///etc/passwd",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
});
|
||||
99
packages/app/src/browser/new-tab-requests/index.ts
Normal file
99
packages/app/src/browser/new-tab-requests/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from "react";
|
||||
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
|
||||
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
|
||||
|
||||
function isAllowedBrowserNewTabUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = payload as Partial<BrowserNewTabRequest>;
|
||||
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceBrowserId: candidate.sourceBrowserId,
|
||||
url: candidate.url,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceContainsBrowser(input: {
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
browserId: string;
|
||||
}): boolean {
|
||||
if (!input.workspaceLayout) {
|
||||
return false;
|
||||
}
|
||||
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
|
||||
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBrowserNewTabRequest(input: {
|
||||
payload: unknown;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
}): BrowserNewTabRequest | null {
|
||||
const request = readDesktopBrowserNewTabRequest(input.payload);
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!workspaceContainsBrowser({
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
browserId: request.sourceBrowserId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useDesktopBrowserNewTabRequests(input: {
|
||||
enabled: boolean;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
openUrl: (url: string) => void;
|
||||
}): void {
|
||||
const handleNewTabRequest = useStableEvent((payload: unknown) => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload,
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
});
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
input.openUrl(request.url);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.enabled || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = getDesktopHost()?.events?.on?.(
|
||||
"browser-new-tab-request",
|
||||
handleNewTabRequest,
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {
|
||||
void unsubscribe?.then((dispose) => dispose());
|
||||
};
|
||||
}, [handleNewTabRequest, input.enabled]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("adds the bottom inset to compact sheet footers", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
hasFooter: true,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ footerPaddingBottom: 46 });
|
||||
});
|
||||
|
||||
it("adds the bottom inset to compact sheet content when there is no footer", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ contentPaddingBottom: 58 });
|
||||
});
|
||||
|
||||
it("does not inset desktop sheets", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
30
packages/app/src/components/adaptive-modal-sheet-layout.ts
Normal file
30
packages/app/src/components/adaptive-modal-sheet-layout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface CompactSheetSafeAreaPaddingInput {
|
||||
isCompact: boolean;
|
||||
hasFooter: boolean;
|
||||
baseContentPadding: number;
|
||||
baseFooterPadding: number;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
export interface CompactSheetSafeAreaPadding {
|
||||
contentPaddingBottom?: number;
|
||||
footerPaddingBottom?: number;
|
||||
}
|
||||
|
||||
export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
hasFooter,
|
||||
baseContentPadding,
|
||||
baseFooterPadding,
|
||||
safeAreaBottom,
|
||||
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
|
||||
if (!isCompact || safeAreaBottom <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (hasFooter) {
|
||||
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
|
||||
}
|
||||
|
||||
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BottomSheetTextInput,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
@@ -19,7 +20,9 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
// leading icon, search input icon) and any row primitive rendered inside the
|
||||
@@ -230,14 +233,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
() => [
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
],
|
||||
[style, theme.colors.surface1, theme.borderRadius.xl],
|
||||
[style, theme.colors.surface0, theme.borderRadius],
|
||||
);
|
||||
return <View style={combinedStyle} />;
|
||||
return <Animated.View pointerEvents="none" style={combinedStyle} />;
|
||||
}
|
||||
|
||||
export type AdaptiveTextInputProps = TextInputProps & {
|
||||
@@ -442,6 +445,7 @@ export interface AdaptiveModalSheetProps {
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -455,13 +459,53 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: isMobile,
|
||||
hasFooter: Boolean(footer),
|
||||
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
baseFooterPadding: theme.spacing[3],
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isMobile, theme.spacing],
|
||||
);
|
||||
const bottomSheetContentStyle = useMemo(
|
||||
() => [
|
||||
styles.bottomSheetContent,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
);
|
||||
const bottomSheetStaticContentStyle = useMemo(
|
||||
() => [
|
||||
styles.bottomSheetStaticContent,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [
|
||||
styles.footer,
|
||||
compactSafeAreaPadding.footerPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.surface2 }),
|
||||
[theme.colors.surface2],
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
@@ -502,20 +546,21 @@ export function AdaptiveModalSheet({
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
contentContainerStyle={bottomSheetContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
<View style={bottomSheetStaticContentStyle}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -534,7 +579,7 @@ export function AdaptiveModalSheet({
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ export function AgentList({
|
||||
|
||||
const handleAgentLongPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||
const isRunning = agent.status === "running";
|
||||
if (isRunning) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
|
||||
@@ -63,8 +63,6 @@ import {
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||
@@ -198,12 +196,10 @@ function ModelRow({
|
||||
],
|
||||
);
|
||||
|
||||
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
description={row.description}
|
||||
selected={isSelected}
|
||||
elevated={elevated}
|
||||
onPress={onPress}
|
||||
@@ -685,10 +681,6 @@ export function CombinedModelSelector({
|
||||
handleOpenChange(!isOpen);
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
@@ -714,11 +706,10 @@ export function CombinedModelSelector({
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const openHeaderProviderSettings = useCallback(() => {
|
||||
const openProviderSettings = useCallback(() => {
|
||||
if (!serverId || view.kind !== "provider") return;
|
||||
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
||||
handleClose();
|
||||
}, [serverId, view, handleClose]);
|
||||
}, [serverId, view]);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
@@ -727,7 +718,7 @@ export function CombinedModelSelector({
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openHeaderProviderSettings}
|
||||
onPress={openProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
@@ -760,7 +751,7 @@ export function CombinedModelSelector({
|
||||
view,
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openHeaderProviderSettings,
|
||||
openProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
|
||||
@@ -9,24 +9,39 @@ import {
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
}
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface CommandCenterRowProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
registerRow: (el: View | null) => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
}
|
||||
|
||||
const CommandCenterRow = memo(function CommandCenterRow({
|
||||
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
children,
|
||||
onPress,
|
||||
registerRow,
|
||||
onLayout,
|
||||
}: CommandCenterRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onPress: () => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
|
||||
active,
|
||||
rowRefs,
|
||||
onPress,
|
||||
onLayout,
|
||||
children,
|
||||
}: CommandCenterRowContainerProps) {
|
||||
const registerRow = useCallback(
|
||||
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
|
||||
[rowRefs, rowIndex],
|
||||
);
|
||||
return (
|
||||
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
|
||||
<CommandCenterRow
|
||||
active={active}
|
||||
registerRow={registerRow}
|
||||
onPress={onPress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRow>
|
||||
);
|
||||
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
}
|
||||
|
||||
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
}: CommandCenterActionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
item,
|
||||
children,
|
||||
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRowContainer>
|
||||
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
|
||||
actionItemsLength: number;
|
||||
activeIndex: number;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onRowLayout: (
|
||||
rowIndex: number,
|
||||
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
@@ -219,6 +251,7 @@ function AgentItemsSection({
|
||||
actionItemsLength,
|
||||
activeIndex,
|
||||
rowRefs,
|
||||
onRowLayout,
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
@@ -237,6 +270,7 @@ function AgentItemsSection({
|
||||
rowIndex={rowIndex}
|
||||
active={rowIndex === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
@@ -249,50 +283,119 @@ function AgentItemsSection({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
|
||||
useCommandCenter();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
} = useCommandCenter();
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
const nativeScrollY = useRef(0);
|
||||
const nativeViewHeight = useRef(0);
|
||||
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
|
||||
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bottomSheetInputRef = useRef<any>(null);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
// Focus the bottom sheet input when the sheet opens on mobile
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
if (showBottomSheet && open) {
|
||||
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
}, [showBottomSheet, open]);
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll active row into view
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (isWeb) {
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") return;
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
// Native: use onLayout-measured positions
|
||||
const layout = rowLayouts.current.get(activeIndex);
|
||||
if (!layout || !resultsRef.current) return;
|
||||
|
||||
const rowTop = layout.y;
|
||||
const rowBottom = rowTop + layout.height;
|
||||
const visibleTop = nativeScrollY.current;
|
||||
const visibleBottom = visibleTop + nativeViewHeight.current;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
|
||||
} else if (rowBottom > visibleBottom) {
|
||||
resultsRef.current.scrollTo?.({
|
||||
y: rowBottom - nativeViewHeight.current,
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
|
||||
rowLayouts.current.set(rowIndex, {
|
||||
y: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
|
||||
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
|
||||
|
||||
@@ -324,8 +427,101 @@ export function CommandCenter() {
|
||||
[theme.colors.border],
|
||||
);
|
||||
|
||||
if (isNative || !open) return null;
|
||||
const handleKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
handleKeyEvent(key);
|
||||
},
|
||||
[handleKeyEvent],
|
||||
);
|
||||
|
||||
const handleSubmitEditing = useCallback(() => {
|
||||
handleKeyEvent("Enter");
|
||||
}, [handleKeyEvent]);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={handleRowLayout(index)}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onRowLayout={handleRowLayout}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile: bottom sheet
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef as unknown as React.Ref<never>}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder="Type a command or search agents..."
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Desktop web: centered overlay panel
|
||||
return (
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
@@ -354,39 +550,7 @@ export function CommandCenter() {
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{resultList}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
@@ -395,6 +559,12 @@ export function CommandCenter() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -28,6 +28,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 20,
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export function DraggableList<T>({
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
extraData,
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
@@ -116,6 +117,7 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activation distance reduces accidental drag capture while nested
|
||||
// lists are inside a scroll container.
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface DraggableListProps<T> {
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
contentContainerFlexGrow?: boolean;
|
||||
/** External row state that should invalidate virtualized native cells. */
|
||||
extraData?: unknown;
|
||||
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||
|
||||
@@ -135,6 +135,7 @@ export function DraggableList<T>({
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
useDragHandle = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
onDragBegin,
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
@@ -63,6 +68,8 @@ export function ExplorerSidebar({
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
@@ -114,10 +121,11 @@ export function ExplorerSidebar({
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
mobilePanelGestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, showMobileAgent]);
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
const enableSidebarCloseGesture = isMobile;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
@@ -158,6 +166,11 @@ export function ExplorerSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
@@ -193,9 +206,11 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
});
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -207,6 +222,7 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleCloseFromGesture,
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import Markdown, {
|
||||
type ASTNode,
|
||||
MarkdownIt,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -22,6 +33,8 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -46,6 +59,8 @@ interface FilePreviewBodyProps {
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -116,6 +131,299 @@ function clampLineSelection(input: {
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface FilePreviewMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FilePreviewMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: FilePreviewMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
function createFilePreviewMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
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>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<FilePreviewMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</FilePreviewMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
@@ -198,6 +506,7 @@ function FilePreviewBody({
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
@@ -280,9 +589,11 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
<AppearanceStyleBoundary>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</AppearanceStyleBoundary>
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
const EDITOR_APP_IMAGES: Record<string, 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"),
|
||||
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
|
||||
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
|
||||
}
|
||||
|
||||
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
const source = EDITOR_APP_IMAGES[editorId];
|
||||
if (!source) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
|
||||
return <Image source={source} style={imageStyle} resizeMode="contain" />;
|
||||
}
|
||||
|
||||
14
packages/app/src/components/icons/omp-icon.tsx
Normal file
14
packages/app/src/components/icons/omp-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
|
||||
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
|
||||
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns only enabled importable providers", () => {
|
||||
it("returns enabled providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude" },
|
||||
{ provider: "codex" },
|
||||
{ provider: "opencode", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual(["claude", "codex"]);
|
||||
expect(providers).toEqual(["claude", "codex", "z-ai"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when snapshot has no enabled importable providers", () => {
|
||||
it("returns an empty array when snapshot has no enabled providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
{ provider: "z-ai", enabled: false },
|
||||
]);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
|
||||
|
||||
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
@@ -28,9 +26,7 @@ export function resolveProvidersToFetch(
|
||||
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
|
||||
if (!supportsSnapshot) return null;
|
||||
if (!snapshotEntries) return null;
|
||||
return snapshotEntries
|
||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
||||
.map((entry) => entry.provider);
|
||||
return snapshotEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
export function buildProviderLabelMap(
|
||||
|
||||
@@ -71,7 +71,9 @@ vi.mock("lucide-react-native", () => {
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ChevronDown: icon("ChevronDown"),
|
||||
Inbox: icon("Inbox"),
|
||||
Layers: icon("Layers"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
};
|
||||
});
|
||||
@@ -81,35 +83,38 @@ vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/segmented-control", () => ({
|
||||
SegmentedControl: ({
|
||||
vi.mock("@/components/ui/combobox", () => ({
|
||||
Combobox: ({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
testID,
|
||||
onSelect,
|
||||
open,
|
||||
}: {
|
||||
options: ReadonlyArray<{ value: string; label: string; testID?: string }>;
|
||||
options: ReadonlyArray<{ id: string; label: string }>;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
onSelect: (id: string) => void;
|
||||
open?: boolean;
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": testID },
|
||||
{ "data-testid": "import-session-combobox" },
|
||||
options.map((option) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
key: option.value,
|
||||
key: option.id,
|
||||
type: "button",
|
||||
"data-testid": option.testID,
|
||||
"data-selected": value === option.value,
|
||||
onClick: () => onValueChange(option.value),
|
||||
"data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`,
|
||||
"data-selected": value === option.id,
|
||||
onClick: () => onSelect(option.id),
|
||||
},
|
||||
option.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
@@ -475,7 +480,13 @@ describe("ImportSessionSheet", () => {
|
||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onClose = vi.fn();
|
||||
@@ -499,7 +510,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
});
|
||||
});
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
@@ -541,7 +552,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
|
||||
it("fans out one request per enabled provider when snapshot is supported", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => ({
|
||||
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
||||
@@ -591,12 +602,15 @@ describe("ImportSessionSheet", () => {
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["opencode"] }),
|
||||
);
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["z-ai"] }),
|
||||
);
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["z-ai"],
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Session z-ai");
|
||||
});
|
||||
|
||||
it("shows partial-failure note when one provider request fails but others succeed", async () => {
|
||||
@@ -675,11 +689,13 @@ describe("ImportSessionSheet", () => {
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
|
||||
|
||||
screen.getByText("Session codex");
|
||||
expect(screen.queryByText("Session claude")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-all"));
|
||||
|
||||
screen.getByText("Session claude");
|
||||
@@ -716,7 +732,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
|
||||
it("shows a no-importable-providers message when snapshot has no enabled providers", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn();
|
||||
const importAgent = vi.fn();
|
||||
|
||||
@@ -732,7 +748,7 @@ describe("ImportSessionSheet", () => {
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
createSnapshotEntry("codex", { enabled: false }),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
createSnapshotEntry("z-ai", { enabled: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { Inbox, RotateCw } from "lucide-react-native";
|
||||
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -88,6 +88,7 @@ interface SheetStatusMessagesProps {
|
||||
isSnapshotUnsupported: boolean;
|
||||
hasNoImportableProviders: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
hasRows: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
erroredProviderLabels: ReadonlyArray<string>;
|
||||
importErrored: boolean;
|
||||
@@ -98,6 +99,7 @@ function SheetStatusMessages({
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
@@ -114,7 +116,7 @@ function SheetStatusMessages({
|
||||
{hasNoImportableProviders ? (
|
||||
<Text style={styles.statusText}>No importable providers are enabled.</Text>
|
||||
) : null}
|
||||
{isLoadingSessions ? (
|
||||
{isLoadingSessions && !hasRows ? (
|
||||
<View style={styles.statusRow}>
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.statusText}>Loading recent sessions...</Text>
|
||||
@@ -176,25 +178,6 @@ function SheetEmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderFilterOptions(
|
||||
providers: ReadonlyArray<string>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): SegmentedControlOption<string>[] {
|
||||
const options: SegmentedControlOption<string>[] = [
|
||||
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
|
||||
];
|
||||
for (const provider of providers) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
options.push({
|
||||
value: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
testID: `import-session-filter-${provider}`,
|
||||
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function ImportSessionSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
@@ -271,6 +254,7 @@ export function ImportSessionSheet({
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
cwd,
|
||||
@@ -315,6 +299,8 @@ export function ImportSessionSheet({
|
||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -330,24 +316,84 @@ export function ImportSessionSheet({
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||
}, [aggregatedEntries, selectedProvider]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => buildProviderFilterOptions(filterProviders, providerLabelById),
|
||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: "All providers" },
|
||||
...filterProviders.map((provider) => ({
|
||||
id: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
})),
|
||||
],
|
||||
[filterProviders, providerLabelById],
|
||||
);
|
||||
|
||||
const selectedProviderLabel = useMemo(
|
||||
() =>
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
|
||||
[filterComboboxOptions, selectedProvider],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFilterSelect = useCallback((id: string) => {
|
||||
setSelectedProvider(id);
|
||||
setIsFilterOpen(false);
|
||||
}, []);
|
||||
|
||||
const filterOptionIcons = useMemo(() => {
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={filterOptionIcons.get(option.id)}
|
||||
/>
|
||||
),
|
||||
[filterOptionIcons],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const effectiveCwd = cwd ?? entry.cwd;
|
||||
if (!effectiveCwd) {
|
||||
if (!entry.cwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
}
|
||||
const agent = await client.importAgent({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: effectiveCwd,
|
||||
cwd: entry.cwd,
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
@@ -423,25 +469,48 @@ export function ImportSessionSheet({
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showFilter ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
>
|
||||
<SegmentedControl
|
||||
testID="import-session-filters"
|
||||
size="sm"
|
||||
options={filterOptions}
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
@@ -466,10 +535,32 @@ export function ImportSessionSheet({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
filterTriggerWrap: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
list: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -34,12 +34,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
@@ -47,6 +52,8 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
useSidebarWorkspacesList,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
@@ -59,6 +66,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
@@ -89,11 +97,13 @@ interface SidebarSharedProps {
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleHostSelect: (nextServerId: string) => void;
|
||||
handleNewWorkspaceNavigate: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
@@ -109,7 +119,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeToAgent: () => void;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
}
|
||||
|
||||
@@ -192,6 +202,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||
);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -216,6 +230,11 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
router.push(buildHostNewWorkspaceRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
@@ -268,6 +287,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
@@ -283,7 +303,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeToAgent={showMobileAgent}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
@@ -297,6 +318,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -417,6 +439,21 @@ function AddProjectTooltipContent({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
}: {
|
||||
label: string;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
activeServerId,
|
||||
@@ -518,19 +555,21 @@ function MobileSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeToAgent,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -542,6 +581,7 @@ function MobileSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
@@ -550,8 +590,8 @@ function MobileSidebar({
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
closeSidebar();
|
||||
}, [closeSidebar, gestureAnimatingRef]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
@@ -559,26 +599,31 @@ function MobileSidebar({
|
||||
}
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeToAgent();
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeToAgent,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
translateX,
|
||||
windowWidth,
|
||||
]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const handleNewWorkspace = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleNewWorkspaceNavigate();
|
||||
}, [closeSidebar, handleNewWorkspaceNavigate]);
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
.enabled(true)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
@@ -600,6 +645,11 @@ function MobileSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
@@ -629,9 +679,11 @@ function MobileSidebar({
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -639,11 +691,11 @@ function MobileSidebar({
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -698,16 +750,22 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspace}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeToAgent}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
@@ -733,6 +791,7 @@ function MobileSidebar({
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -777,12 +836,14 @@ function DesktopSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -860,14 +921,20 @@ function DesktopSidebar({
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspaceNavigate}
|
||||
/>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -877,6 +944,7 @@ function DesktopSidebar({
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -911,6 +979,90 @@ function DesktopSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacesSectionHeader({
|
||||
serverId,
|
||||
onNewWorkspacePress,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
onNewWorkspacePress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
|
||||
const searchButtonStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.workspacesHeaderIconButton,
|
||||
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New workspace"
|
||||
testID="sidebar-new-workspace"
|
||||
style={searchButtonStyle}
|
||||
onPress={onNewWorkspacePress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="New workspace" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open command center"
|
||||
testID="sidebar-command-center-search"
|
||||
style={searchButtonStyle}
|
||||
onPress={handleSearchPress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Search
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarGroupingSelector serverId={serverId} />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -932,6 +1084,39 @@ const staticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderRow: {
|
||||
position: "relative",
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[4],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
workspacesSectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
workspacesSectionActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspacesHeaderIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
workspacesHeaderIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
32
packages/app/src/components/markdown-text-style.test.ts
Normal file
32
packages/app/src/components/markdown-text-style.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StyleSheet, type TextStyle } from "react-native";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
|
||||
function unistylesStyle(id: string, style: Record<string, unknown>) {
|
||||
return {
|
||||
...style,
|
||||
[`unistyles_${id}`]: { id },
|
||||
};
|
||||
}
|
||||
|
||||
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
|
||||
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
|
||||
// before passing the result to its native View-backed components.
|
||||
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function unistylesMetadataKeys(style: Record<string, unknown>) {
|
||||
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
|
||||
}
|
||||
|
||||
describe("resolvePlainMarkdownTextStyle", () => {
|
||||
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
|
||||
const merged = uiTextViewFlatten(
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
|
||||
);
|
||||
|
||||
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
|
||||
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
|
||||
});
|
||||
});
|
||||
25
packages/app/src/components/markdown-text-style.ts
Normal file
25
packages/app/src/components/markdown-text-style.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
|
||||
|
||||
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
|
||||
}
|
||||
|
||||
function stripUnistylesMetadata(style: TextStyle): TextStyle {
|
||||
// iOS markdown text goes through react-native-uitextview. That library
|
||||
// inherits text styles by flattening [parentStyle, childStyle] before handing
|
||||
// the result to native View-backed components. If both entries are Unistyles
|
||||
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
|
||||
// and Unistyles correctly warns that the style should have stayed array-shaped.
|
||||
//
|
||||
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
|
||||
// our ownership model. Resolve the concrete style values before crossing that
|
||||
// boundary and drop only Unistyles' private tracking metadata. This preserves
|
||||
// iOS paragraph/spanning text selection while avoiding the metadata merge.
|
||||
const plainStyle: Record<string, unknown> = { ...style };
|
||||
for (const key of Object.keys(plainStyle)) {
|
||||
if (key.startsWith("unistyles_")) {
|
||||
delete plainStyle[key];
|
||||
}
|
||||
}
|
||||
return plainStyle as TextStyle;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
|
||||
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
@@ -8,10 +9,11 @@ interface MarkdownTextSpanProps {
|
||||
children: ReactNode;
|
||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
||||
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
||||
// must be a UITextView span to be visible. onPress is forwarded best-effort:
|
||||
// react-native-uitextview nulls onPress on the root native view, so reliable
|
||||
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
|
||||
// invisible link.
|
||||
// must be a UITextView span to be visible. onPress is wired onto the leaf
|
||||
// string children here: react-native-uitextview attaches it to the
|
||||
// RNUITextViewChild nodes it builds from string content, which the native tap
|
||||
// recognizer dispatches to. The link's handler reaches these leaf spans via
|
||||
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
@@ -26,11 +28,13 @@ export function MarkdownTextSpan({
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
uiTextView
|
||||
selectable
|
||||
style={style}
|
||||
style={plainStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
@@ -41,6 +45,7 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -52,13 +57,27 @@ const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
// ViewStyle is structurally compatible with the layout props paragraphs use
|
||||
// (margin, padding, alignment); the cast lets the existing paragraphStyle
|
||||
// flow through unchanged.
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(
|
||||
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
|
||||
export function MarkdownParagraphView({
|
||||
paragraphStyle,
|
||||
containsImage = false,
|
||||
children,
|
||||
}: MarkdownParagraphViewProps) {
|
||||
const textStyle = useMemo(
|
||||
() =>
|
||||
resolvePlainMarkdownTextStyle([
|
||||
paragraphStyle,
|
||||
MARKDOWN_PARAGRAPH_RESET,
|
||||
] as StyleProp<TextStyle>),
|
||||
[paragraphStyle],
|
||||
);
|
||||
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
|
||||
if (containsImage) {
|
||||
return <View style={viewStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
<UITextView uiTextView selectable style={textStyle}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkActions,
|
||||
useAssistantLinkPress,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -1531,8 +1533,19 @@ function MarkdownInheritedText({
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
// When this span renders link label text on iOS, pick up the link's press
|
||||
// handler from context and hand it to MarkdownTextSpan, which forwards it to
|
||||
// the leaf string children react-native-uitextview makes tappable. Null
|
||||
// outside a link (and on every other platform, where no provider mounts), so
|
||||
// ordinary text is unaffected. See assistant-file-links/link-press-context.
|
||||
const linkPress = useAssistantLinkPress();
|
||||
return (
|
||||
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
style={style}
|
||||
onPress={linkPress?.onPress}
|
||||
accessibilityRole={linkPress?.accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
@@ -1622,6 +1635,67 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// strong/em/s have no custom rule in react-native-markdown-display's
|
||||
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
|
||||
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
|
||||
// and a plain <Text> nested inside one is not hoisted into a
|
||||
// UITextViewChild, so its content renders invisibly. Route these inline
|
||||
// marks through MarkdownTextSpan (same path as text/textgroup) so the
|
||||
// styled content composes and stays visible + selectable on iOS.
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.em}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.s}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// hardbreak/softbreak fall back to react-native-markdown-display's
|
||||
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
|
||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
||||
// Android keep the same "\n" they rendered before.
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
@@ -1764,7 +1838,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
buildAcpProviderConfigPatch,
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
installingProviderId: string | null;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type InstallState = "installed" | "available";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ACTION_BUTTON_STYLE = { width: 92 } as const;
|
||||
const MODAL_SNAP_POINTS = ["78%", "92%"];
|
||||
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
|
||||
const SEARCH_ICON_SIZE = 16;
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
@@ -45,14 +33,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function getInstallState(
|
||||
entry: AcpProviderCatalogItem,
|
||||
installedProviderIds: Set<string>,
|
||||
): InstallState {
|
||||
if (installedProviderIds.has(entry.id)) return "installed";
|
||||
return "available";
|
||||
}
|
||||
|
||||
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
@@ -61,22 +41,13 @@ function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderCatalogRowProps {
|
||||
interface CatalogRowProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
state: InstallState;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) {
|
||||
const isAvailable = state === "available";
|
||||
let actionLabel = "Add";
|
||||
if (installing) {
|
||||
actionLabel = "Adding";
|
||||
} else if (state === "installed") {
|
||||
actionLabel = "Installed";
|
||||
}
|
||||
|
||||
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
@@ -125,72 +96,43 @@ function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCat
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAvailable ? "default" : "secondary"}
|
||||
disabled={!isAvailable || installing}
|
||||
variant="default"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
style={ACTION_BUTTON_STYLE}
|
||||
style={styles.actionButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{actionLabel}
|
||||
{installing ? "Adding" : "Add"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) {
|
||||
const { entries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
export function ProviderCatalogList({
|
||||
serverId,
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearch("");
|
||||
bumpSearchResetKey();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const installedProviderIds = useMemo(
|
||||
const installedIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
);
|
||||
const filteredEntries = useMemo(
|
||||
() => entries.filter((entry) => matchesSearch(entry, search)),
|
||||
[entries, search],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry));
|
||||
await refresh([entry.id]);
|
||||
handleClose();
|
||||
} catch (installError) {
|
||||
Alert.alert(
|
||||
"Unable to install provider",
|
||||
installError instanceof Error ? installError.message : String(installError),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, handleClose, patchConfig, refresh],
|
||||
const availableEntries = useMemo(
|
||||
() =>
|
||||
catalogEntries
|
||||
.filter((entry) => !installedIds.has(entry.id))
|
||||
.filter((entry) => matchesSearch(entry, search)),
|
||||
[catalogEntries, installedIds, search],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={ADD_PROVIDER_HEADER}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
desktopMaxWidth={680}
|
||||
snapPoints={MODAL_SNAP_POINTS}
|
||||
testID="add-provider-modal"
|
||||
>
|
||||
<View>
|
||||
<View style={styles.searchField}>
|
||||
<View style={styles.searchIcon}>
|
||||
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
||||
@@ -198,8 +140,6 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
initialValue={search}
|
||||
resetKey={`provider-catalog-search-${searchResetKey}`}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
@@ -209,32 +149,25 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
/>
|
||||
</View>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<Text style={styles.stateText}>No providers found</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{filteredEntries.length > 0 ? (
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{filteredEntries.map((entry) => (
|
||||
<ProviderCatalogRow
|
||||
{availableEntries.map((entry) => (
|
||||
<CatalogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
state={getInstallState(entry, installedProviderIds)}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={handleInstall}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +181,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
searchIcon: {
|
||||
width: 18,
|
||||
@@ -320,6 +254,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
actionButton: {
|
||||
width: 92,
|
||||
flexShrink: 0,
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -335,7 +273,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
}));
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
@@ -195,6 +196,7 @@ function AddCustomModelSubSheet({
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={420}
|
||||
snapPoints={ADD_SNAP_POINTS}
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>Model ID</Text>
|
||||
@@ -338,6 +340,7 @@ function DiagnosticSubSheet({
|
||||
onClose={onClose}
|
||||
snapPoints={DIAGNOSTIC_SNAP_POINTS}
|
||||
scrollable={false}
|
||||
testID="provider-diagnostic-sheet"
|
||||
>
|
||||
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -359,6 +362,69 @@ interface ProviderModalBodyProps {
|
||||
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
}: ProviderSheetFooterInput) {
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta;
|
||||
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={onOpenAddSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Add model
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={FileText}
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Diagnostic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={onRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const {
|
||||
discoveredCount,
|
||||
@@ -446,6 +512,7 @@ export function ProviderDiagnosticSheet({
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -545,38 +612,21 @@ export function ProviderDiagnosticSheet({
|
||||
[providerLabel],
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Text style={sheetStyles.footerMeta} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
</Text>
|
||||
<View style={sheetStyles.footerActions}>
|
||||
<Button variant="secondary" size="sm" leftIcon={Plus} onPress={handleOpenAddSheet}>
|
||||
Add model
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" leftIcon={FileText} onPress={handleOpenDiagSheet}>
|
||||
Diagnostic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={handleRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdaptiveModalSheet
|
||||
header={sheetHeader}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
})}
|
||||
snapPoints={MAIN_SNAP_POINTS}
|
||||
>
|
||||
<ProviderModalBody
|
||||
@@ -692,16 +742,36 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
footerMeta: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
compactFooterMeta: {
|
||||
flex: 0,
|
||||
},
|
||||
footerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterActions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterButton: {
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
formGroup: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
@@ -741,6 +811,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
|
||||
const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta];
|
||||
|
||||
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
|
||||
@@ -5,6 +5,7 @@ describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
|
||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
|
||||
export type BuiltinProviderIconName =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "copilot"
|
||||
| "kiro"
|
||||
| "omp"
|
||||
| "opencode"
|
||||
| "pi";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||
@@ -12,6 +19,7 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
||||
"codex",
|
||||
"copilot",
|
||||
"kiro",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
const visible = useProviderSettingsStore((state) => state.visible);
|
||||
const close = useProviderSettingsStore((state) => state.close);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -19,7 +20,7 @@ export function ProviderSettingsHost() {
|
||||
<ProviderDiagnosticSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -76,4 +76,27 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for questions that allow other answers", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Pick or type",
|
||||
header: "Response",
|
||||
options: [{ label: "A" }],
|
||||
allowOther: true,
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
const [question] = questions;
|
||||
if (!question) throw new Error("question missing");
|
||||
expect(questionShowsTextInput(question)).toBe(true);
|
||||
expect(areQuestionsAnswered(questions, {}, { 0: "custom" })).toBe(true);
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "custom" })).toEqual({
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -82,9 +82,18 @@ function QuestionOptionRow({
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
|
||||
return (
|
||||
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={option.label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
@@ -102,8 +111,73 @@ function QuestionOptionRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionNavButtonProps {
|
||||
index: number;
|
||||
total: number;
|
||||
isActive: boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function QuestionNavButton({
|
||||
index,
|
||||
total,
|
||||
isActive,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(index);
|
||||
}, [index, onSelect]);
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
return [
|
||||
styles.questionNavButton,
|
||||
{
|
||||
backgroundColor:
|
||||
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
];
|
||||
},
|
||||
[
|
||||
isActive,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.surface1,
|
||||
theme.colors.surface2,
|
||||
],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.questionNavText,
|
||||
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isActive}
|
||||
testID={`question-form-question-nav-${index + 1}`}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<Text style={textStyle}>{index + 1}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isResponding: boolean;
|
||||
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
isResponding,
|
||||
@@ -149,6 +224,7 @@ function QuestionOtherInput({
|
||||
<TextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -163,15 +239,19 @@ function QuestionOtherInput({
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
||||
const questions = useMemo(
|
||||
() => parseQuestionFormQuestions(permission.request.input),
|
||||
[permission.request.input],
|
||||
);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
setSelections((prev) => {
|
||||
const current = prev[qIndex] ?? new Set<number>();
|
||||
const toggleOption = useCallback(
|
||||
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
const current = selections[qIndex] ?? new Set<number>();
|
||||
const next = new Set(current);
|
||||
if (multiSelect) {
|
||||
if (next.has(optIndex)) {
|
||||
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
} else {
|
||||
next.add(optIndex);
|
||||
}
|
||||
} else if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
return { ...prev, [qIndex]: next };
|
||||
});
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[qIndex];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}, []);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||
: 0;
|
||||
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
});
|
||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||
|
||||
const handleSelectQuestion = useCallback((index: number) => {
|
||||
setActiveQuestionIndex(index);
|
||||
}, []);
|
||||
|
||||
const dismissButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const submitDisabled = !allAnswered || isResponding;
|
||||
const submitButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
opacity: submitDisabled ? 0.5 : 1,
|
||||
},
|
||||
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
||||
],
|
||||
[
|
||||
submitDisabled,
|
||||
theme.colors.surface2,
|
||||
theme.colors.surface1,
|
||||
theme.colors.border,
|
||||
theme.colors.borderAccent,
|
||||
],
|
||||
[submitDisabled, theme.colors.accent],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const questionNavStyle = useMemo(
|
||||
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||
[isMobile],
|
||||
);
|
||||
const actionsContainerStyle = useMemo(
|
||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||
[isMobile],
|
||||
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const submitActionTextColor = allAnswered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
const submitActionTextColor = theme.colors.accentForeground;
|
||||
const submitActionTextStyle = useMemo(
|
||||
() => [styles.actionText, { color: submitActionTextColor }],
|
||||
[submitActionTextColor],
|
||||
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
const dismissLabel = resolveDismissLabel(questions);
|
||||
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const showTextInput = questionShowsTextInput(q);
|
||||
|
||||
return (
|
||||
<View key={q.question} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={questionTextStyle}>{q.question}</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
{q.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(q)}
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
<View style={styles.questionTopRow}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||
{questions.map((question, qIndex) => {
|
||||
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||
return (
|
||||
<QuestionNavButton
|
||||
key={question.header}
|
||||
index={qIndex}
|
||||
total={questions.length}
|
||||
isActive={isActive}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{activeQuestion ? (
|
||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||
{activeQuestion.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{activeQuestion.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={activeQuestion.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={actionsContainerStyle}>
|
||||
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
|
||||
<Pressable
|
||||
style={dismissButtonStyle}
|
||||
onPress={handleDeny}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={dismissLabel}
|
||||
testID="question-form-dismiss"
|
||||
>
|
||||
{respondingAction === "dismiss" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
|
||||
<Pressable
|
||||
style={submitButtonStyle}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitDisabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Submit"
|
||||
testID="question-form-primary-action"
|
||||
>
|
||||
{respondingAction === "submit" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check size={14} color={submitActionTextColor} />
|
||||
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionBlock: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
questionTopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNav: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNavMobile: {
|
||||
paddingRight: theme.spacing[1],
|
||||
},
|
||||
questionNavButton: {
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 999,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "700",
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
describe("computeResizeHandleSizes", () => {
|
||||
it("clamps right-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.65);
|
||||
expect(sizes[2]).toBeCloseTo(0.1, 10);
|
||||
});
|
||||
|
||||
it("clamps left-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: -0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.1);
|
||||
expect(sizes[2]).toBeCloseTo(0.65, 10);
|
||||
});
|
||||
|
||||
it("moves adjacent pane sizes without clamping", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.55);
|
||||
expect(sizes[2]).toBeCloseTo(0.2, 10);
|
||||
});
|
||||
|
||||
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.45, 0.05, 0.05, 0.45],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
}),
|
||||
).toEqual([0.45, 0.05, 0.05, 0.45]);
|
||||
});
|
||||
|
||||
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 3,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0.5, 0.25]);
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0, 0, 0.75],
|
||||
index: 1,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0, 0, 0.75]);
|
||||
});
|
||||
});
|
||||
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
|
||||
interface ComputeResizeHandleSizesInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
deltaRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export function computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
minSize = MIN_SPLIT_SIZE,
|
||||
}: ComputeResizeHandleSizesInput): number[] {
|
||||
const nextSizes = sizes.slice();
|
||||
const leftSize = sizes[index];
|
||||
const rightSize = sizes[index + 1];
|
||||
if (leftSize === undefined || rightSize === undefined) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const pairSize = leftSize + rightSize;
|
||||
if (pairSize <= 0) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const adjacentMinSize = Math.min(minSize, pairSize / 2);
|
||||
const nextLeftSize = Math.min(
|
||||
pairSize - adjacentMinSize,
|
||||
Math.max(adjacentMinSize, leftSize + deltaRatio),
|
||||
);
|
||||
nextSizes[index] = nextLeftSize;
|
||||
nextSizes[index + 1] = pairSize - nextLeftSize;
|
||||
return nextSizes;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
// Clamp any browser scroll introduced while dragging past the viewport edge.
|
||||
if (window.scrollX === 0) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, window.scrollY);
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -25,7 +32,8 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,7 +42,11 @@ export function ResizeHandle({
|
||||
const handlePointerDown = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!hitAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
@@ -45,51 +57,83 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.nativeEvent.pointerId;
|
||||
if (pointerStatesRef.current.has(pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
pointerStatesRef.current.set(pointerId, {
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
cursorBeforeDragRef.current = document.body.style.cursor;
|
||||
}
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const pointerCaptureElement = hitAreaElement;
|
||||
pointerCaptureElement.setPointerCapture?.(pointerId);
|
||||
resetWindowHorizontalScroll();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
pointerStatesRef.current.delete(pointerId);
|
||||
setDragging(pointerStatesRef.current.size > 0);
|
||||
if (pointerStatesRef.current.size === 0) {
|
||||
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
|
||||
cursorBeforeDragRef.current = null;
|
||||
}
|
||||
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
|
||||
pointerCaptureElement.releasePointerCapture(pointerId);
|
||||
}
|
||||
resetWindowHorizontalScroll();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = pointerStatesRef.current.get(pointerId);
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveEvent.preventDefault();
|
||||
resetWindowHorizontalScroll();
|
||||
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
);
|
||||
@@ -130,6 +174,7 @@ export function ResizeHandle({
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
touchAction: "none",
|
||||
} as object,
|
||||
],
|
||||
[direction],
|
||||
|
||||
@@ -90,6 +90,7 @@ function workspace(input: {
|
||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||
name: input.name,
|
||||
status: input.status ?? "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import { useCallback } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
serverId ? state.getGroupMode(serverId) : "project",
|
||||
);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(mode: SidebarGroupMode) => {
|
||||
if (!serverId) return;
|
||||
setGroupMode(serverId, mode);
|
||||
},
|
||||
[serverId, setGroupMode],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Sidebar grouping"
|
||||
testID="sidebar-grouping-selector"
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Group by</Text>
|
||||
</View>
|
||||
{GROUP_MODE_ITEMS.map((item) => (
|
||||
<GroupModeMenuItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
isSelected={groupMode === item.value}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupModeMenuItem({
|
||||
item,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
item: { value: SidebarGroupMode; label: string };
|
||||
isSelected: boolean;
|
||||
onSelect: (mode: SidebarGroupMode) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-grouping-${item.value}`}
|
||||
selected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
menuHeader: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
menuHeaderLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
931
packages/app/src/components/sidebar/sidebar-status-list.tsx
Normal file
931
packages/app/src/components/sidebar/sidebar-status-list.tsx
Normal file
@@ -0,0 +1,931 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
buildStatusGroups,
|
||||
buildStatusShortcutIndex,
|
||||
type StatusGroup,
|
||||
} from "@/hooks/sidebar-status-view-model";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
Archive,
|
||||
Pencil,
|
||||
} from "lucide-react-native";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
interface StatusWorkspaceListProps {
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
serverId: string | null;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
workspaces,
|
||||
projectNamesByKey,
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const groups = useMemo(
|
||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
||||
[workspaces, projectNamesByKey],
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
|
||||
const statusShortcutIndex = useMemo(
|
||||
() =>
|
||||
showShortcutBadges
|
||||
? buildStatusShortcutIndex(
|
||||
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
||||
)
|
||||
: new Map<string, number>(),
|
||||
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
serverId={serverId}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
serverId={serverId}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupList({
|
||||
groups,
|
||||
collapsedStatusGroupKeys,
|
||||
projectNamesByKey,
|
||||
serverId,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
serverId: string | null;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
||||
<View style={styles.statusWorkspaceListContainer}>
|
||||
{group.rows.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
|
||||
serverId={serverId}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleStatusGroupCollapsed,
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
toggleStatusGroupCollapsed(group.bucket);
|
||||
}, [group.bucket, toggleStatusGroupCollapsed]);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.statusGroupRow,
|
||||
isHovered && styles.statusGroupRowHovered,
|
||||
pressed && styles.statusGroupRowPressed,
|
||||
],
|
||||
[isHovered],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
|
||||
return (
|
||||
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
||||
<Pressable
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={`${group.label} status group`}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-status-group-${group.bucket}`}
|
||||
>
|
||||
<View style={styles.statusGroupRowLeft}>
|
||||
<View style={styles.statusGroupLeadingVisualSlot}>
|
||||
<StatusGroupLeadingVisual
|
||||
bucket={group.bucket}
|
||||
collapsed={collapsed}
|
||||
showChevron={isHovered}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.statusGroupTitleGroup}>
|
||||
<Text style={styles.statusGroupTitle} numberOfLines={1}>
|
||||
{group.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupLeadingVisual({
|
||||
bucket,
|
||||
collapsed,
|
||||
showChevron,
|
||||
}: {
|
||||
bucket: StatusGroup["bucket"];
|
||||
collapsed: boolean;
|
||||
showChevron: boolean;
|
||||
}) {
|
||||
if (!showChevron) {
|
||||
return <StatusGroupIcon bucket={bucket} />;
|
||||
}
|
||||
if (collapsed) {
|
||||
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
|
||||
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
|
||||
case "failed":
|
||||
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
|
||||
case "attention":
|
||||
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
||||
case "running":
|
||||
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
|
||||
case "done":
|
||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
}
|
||||
|
||||
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
workspace,
|
||||
projectName,
|
||||
serverId,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
serverId: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, workspace.workspaceId);
|
||||
}, [serverId, onWorkspacePress, workspace.workspaceId]);
|
||||
|
||||
if (!hydratedWorkspace) return null;
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={hydratedWorkspace}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function StatusWorkspaceRowWithMenu({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selected
|
||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||
: null,
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
if (isArchiving) return;
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
redirectAfterArchive();
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to archive worktree");
|
||||
});
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const hideWorkspaceAfterConfirmation = useCallback(async () => {
|
||||
if (isArchivingWorkspace) return;
|
||||
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 {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error("Host is not connected");
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
onSuccess: async ({ targetCwd }) => {
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: workspace.serverId,
|
||||
cwd: targetCwd,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(slugify(value));
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? "Invalid branch name";
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
if (isWorktree) {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
} else {
|
||||
void hideWorkspaceAfterConfirmation();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = archiveStatus;
|
||||
} else if (isArchivingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
workspace={workspace}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
submitLabel="Rename"
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
isArchiving,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
isArchiving: boolean;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={projectName}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
>
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
showOverlay={showKebabInSlot}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
showBase,
|
||||
showOverlay,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
showBase: boolean;
|
||||
showOverlay: boolean;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<StatusKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
</SidebarWorkspaceTrailingActionSlot>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{({ hovered }: { hovered?: boolean }) => (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
statusGroupBlock: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
statusWorkspaceListContainer: {},
|
||||
statusGroupRow: {
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
statusGroupRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
statusGroupRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusGroupRowLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
statusGroupLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusGroupTitleGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,550 @@
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
isDragging = false,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isDragging?: boolean;
|
||||
children: (input: {
|
||||
isHovered: boolean;
|
||||
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const hoverHandlers = useMemo(
|
||||
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
|
||||
[handlePointerEnter, handlePointerLeave],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
|
||||
{children({ isHovered, hoverHandlers })}
|
||||
</WorkspaceHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
|
||||
workspace,
|
||||
subtitle,
|
||||
scriptIconKind = null,
|
||||
isHovered,
|
||||
isLoading,
|
||||
isCreating = false,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle?: string | null;
|
||||
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
||||
isHovered: boolean;
|
||||
isLoading: boolean;
|
||||
isCreating?: boolean;
|
||||
shortcutNumber?: number | null;
|
||||
showShortcutBadge?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const workspaceBranchTextStyle = useMemo(
|
||||
() => [
|
||||
styles.workspaceBranchText,
|
||||
scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
],
|
||||
[scriptIconKind, isHovered, isCreating],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceRowContent}>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View style={styles.workspaceRowLeft}>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>{children}</View>
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadgeOverlay} pointerEvents="none">
|
||||
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
|
||||
</View>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{workspace.prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<PrBadge hint={workspace.prHint} />
|
||||
<ChecksBadge checks={workspace.prHint.checks} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) {
|
||||
return (
|
||||
<View
|
||||
style={styles.workspaceTitleAccessory}
|
||||
accessibilityLabel="Scripts available"
|
||||
testID={kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
|
||||
>
|
||||
{kind === "service" ? (
|
||||
<ThemedGlobe size={12} uniProps={blueColorMapping} />
|
||||
) : (
|
||||
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
workspaceKind,
|
||||
loading = false,
|
||||
}: {
|
||||
bucket: SidebarWorkspaceEntry["statusBucket"];
|
||||
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
||||
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (bucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = ThemedFolder;
|
||||
|
||||
const dotColorStyle = getStatusDotColorStyle(bucket);
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
|
||||
{dotColorStyle ? (
|
||||
<StatusDotOverlay
|
||||
dotColorStyle={dotColorStyle}
|
||||
size={statusDotSize}
|
||||
offset={statusDotOffset}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDotOverlay({
|
||||
dotColorStyle,
|
||||
size,
|
||||
offset,
|
||||
}: {
|
||||
dotColorStyle: ViewStyle;
|
||||
size: number;
|
||||
offset: number;
|
||||
}) {
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDotOverlay,
|
||||
dotColorStyle,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
right: offset,
|
||||
bottom: offset,
|
||||
},
|
||||
],
|
||||
[dotColorStyle, offset, size],
|
||||
);
|
||||
return <View style={overlayStyle} />;
|
||||
}
|
||||
|
||||
function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(hint.url);
|
||||
},
|
||||
[hint.url],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text),
|
||||
[isHovered],
|
||||
);
|
||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const pressableStyle = useMemo(
|
||||
() => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed],
|
||||
[isHovered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Pull request #${hint.number}`}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{isHovered ? (
|
||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||
) : (
|
||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
#{hint.number}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
const failed = checks.filter((check) => check.status === "failure").length;
|
||||
if (failed === 0) return null;
|
||||
return (
|
||||
<View style={checksBadgeStyles.badge}>
|
||||
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
|
||||
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return purpleColorMapping;
|
||||
case "open":
|
||||
return greenColorMapping;
|
||||
case "closed":
|
||||
return redColorMapping;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return styles.statusDotNeedsInput;
|
||||
case "failed":
|
||||
return styles.statusDotFailed;
|
||||
case "running":
|
||||
return styles.statusDotRunning;
|
||||
case "attention":
|
||||
return styles.statusDotAttention;
|
||||
case "done":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
badgePressed: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
const checksBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
}));
|
||||
|
||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
rowRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
shortcutBadge: {
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
shortcutBadgeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 14,
|
||||
},
|
||||
hidden: { opacity: 0 },
|
||||
trailingActionSlot: {
|
||||
position: "relative",
|
||||
minWidth: 18,
|
||||
minHeight: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
trailingActionOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
return (
|
||||
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
|
||||
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{number}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionBase({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!children) return null;
|
||||
return <View style={visible ? undefined : sidebarWorkspaceRowStyles.hidden}>{children}</View>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionOverlay({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!visible || !children) return null;
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContent: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRowMain: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
width: "100%",
|
||||
},
|
||||
workspaceRowLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
|
||||
shortcutBadgeOverlay: {
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
right: 0,
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: WORKSPACE_STATUS_DOT_WIDTH,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
opacity: 0.76,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextFlexible: {
|
||||
flex: 1,
|
||||
},
|
||||
workspaceBranchTextWithAccessory: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceTitleAccessory: {
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
workspaceSubtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 14,
|
||||
marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
|
||||
},
|
||||
workspacePrBadgeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[1],
|
||||
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotFailed: {
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotRunning: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotAttention: {
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user