mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
|
||||
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",
|
||||
|
||||
97
CHANGELOG.md
97
CHANGELOG.md
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -577,7 +612,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 +639,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,7 +70,7 @@ 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
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, 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)
|
||||
|
||||
@@ -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,12 +24,16 @@ 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.
|
||||
|
||||
---
|
||||
@@ -260,7 +264,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[] = [
|
||||
|
||||
@@ -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.
|
||||
@@ -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-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.91",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.91",
|
||||
"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.91",
|
||||
"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.91",
|
||||
"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.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"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.91",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.88",
|
||||
"@getpaseo/relay": "0.1.88",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.91",
|
||||
"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.91",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.91",
|
||||
"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.91",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.88",
|
||||
"version": "0.1.91",
|
||||
"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.91",
|
||||
"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.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@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.91",
|
||||
"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.91",
|
||||
"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",
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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();
|
||||
@@ -231,9 +295,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();
|
||||
@@ -296,9 +360,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);
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
@@ -26,7 +27,12 @@ import {
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
expectAppearanceContent,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
@@ -157,4 +163,28 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.91",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -493,7 +496,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 +505,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 +541,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 +587,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 +603,7 @@ function MobileGestureWrapper({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleGestureOpen,
|
||||
@@ -921,17 +932,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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -685,10 +685,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 +710,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 +722,7 @@ export function CombinedModelSelector({
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openHeaderProviderSettings}
|
||||
onPress={openProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
@@ -760,7 +755,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,15 +1,26 @@
|
||||
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,
|
||||
Linking,
|
||||
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";
|
||||
@@ -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 Linking.openURL(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" />;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -541,7 +541,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 +591,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 () => {
|
||||
@@ -716,7 +719,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 +735,7 @@ describe("ImportSessionSheet", () => {
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
createSnapshotEntry("codex", { enabled: false }),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
createSnapshotEntry("z-ai", { enabled: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -24,6 +24,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
omp: PiIcon 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,
|
||||
},
|
||||
}));
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
@@ -94,6 +95,7 @@ export interface ComboboxProps {
|
||||
*/
|
||||
header?: SheetHeader;
|
||||
mobileChildrenScrollEnabled?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
@@ -964,6 +966,7 @@ interface MobileBodyProps {
|
||||
searchable: boolean;
|
||||
hasChildren: boolean;
|
||||
mobileChildrenScrollEnabled: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
searchResetKey: number;
|
||||
searchPlaceholder: string;
|
||||
searchQuery: string;
|
||||
@@ -1022,7 +1025,8 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={props.handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
@@ -1247,6 +1251,7 @@ export function Combobox({
|
||||
title = "Select",
|
||||
header,
|
||||
mobileChildrenScrollEnabled = true,
|
||||
presentation,
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
@@ -1482,6 +1487,7 @@ export function Combobox({
|
||||
);
|
||||
|
||||
useWebKeyboardListener(isOpen, handleDesktopKey);
|
||||
useDismissKeyboardOnOpen(isOpen, isMobile);
|
||||
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
@@ -1532,6 +1538,7 @@ export function Combobox({
|
||||
searchable={searchable}
|
||||
hasChildren={hasChildren}
|
||||
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
||||
presentation={presentation}
|
||||
searchResetKey={searchResetKey}
|
||||
searchPlaceholder={effectiveSearchPlaceholder}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -244,6 +245,7 @@ export function DropdownMenu({
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
});
|
||||
useDismissKeyboardOnOpen(isOpen);
|
||||
|
||||
const flushPendingSelect = useCallback(() => {
|
||||
const pendingSelect = pendingSelectRef.current;
|
||||
|
||||
@@ -21,9 +21,12 @@ export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function F
|
||||
): ReactElement {
|
||||
const inlineFrameStyle = useMemo(() => {
|
||||
const flattened = StyleSheet.flatten(frameStyle);
|
||||
return flattened ? inlineUnistylesStyle(flattened) : undefined;
|
||||
return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined;
|
||||
}, [frameStyle]);
|
||||
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
|
||||
const surfaceStyle = useMemo(
|
||||
() => appendStyle(style, inlineFrameStyle),
|
||||
[inlineFrameStyle, style],
|
||||
);
|
||||
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
|
||||
});
|
||||
|
||||
@@ -46,7 +49,7 @@ export function FloatingScrollView({
|
||||
}: FloatingScrollViewProps): ReactElement {
|
||||
const inlineStyle = useMemo(() => {
|
||||
const flattened = StyleSheet.flatten(style);
|
||||
return flattened ? inlineUnistylesStyle(flattened) : undefined;
|
||||
return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined;
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
@@ -61,3 +64,26 @@ export function FloatingScrollView({
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function appendStyle(
|
||||
style: StyleProp<ViewStyle>,
|
||||
extraStyle: ViewStyle | undefined,
|
||||
): StyleProp<ViewStyle> {
|
||||
if (!extraStyle) {
|
||||
return style;
|
||||
}
|
||||
if (Array.isArray(style)) {
|
||||
return [...style, extraStyle];
|
||||
}
|
||||
return [style, extraStyle];
|
||||
}
|
||||
|
||||
function stripUnistylesMetadata(style: ViewStyle): ViewStyle {
|
||||
const cleanStyle: Record<string, unknown> = { ...style };
|
||||
for (const key of Object.keys(cleanStyle)) {
|
||||
if (key.startsWith("unistyles_")) {
|
||||
delete cleanStyle[key];
|
||||
}
|
||||
}
|
||||
return cleanStyle as ViewStyle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
|
||||
React.createElement("div", { "data-portal-host": hostName }, children),
|
||||
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", () => ({
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
stackBehavior,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
stackBehavior?: string;
|
||||
},
|
||||
_ref,
|
||||
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
}));
|
||||
|
||||
import { IsolatedBottomSheetModal } from ".";
|
||||
|
||||
describe("IsolatedBottomSheetModal presentation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
),
|
||||
).toEqual(["push", "push"]);
|
||||
});
|
||||
|
||||
it("only replaces when the callsite asks for replacement", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
|
||||
).toBe("replace");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
BottomSheetModal as GorhomBottomSheetModal,
|
||||
BottomSheetModalProvider,
|
||||
type BottomSheetModalProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import React, { createContext, useContext } from "react";
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef } from "react";
|
||||
import {
|
||||
@@ -17,50 +15,29 @@ type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior"
|
||||
>;
|
||||
> & {
|
||||
presentation?: "push" | "replace";
|
||||
};
|
||||
|
||||
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||
|
||||
const IsolatedBottomSheetScopeContext = createContext(false);
|
||||
|
||||
export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
|
||||
const { children, ...bottomSheetProps } = props;
|
||||
const scopedChildren =
|
||||
typeof children === "function" ? (
|
||||
(input: { data?: unknown }) => (
|
||||
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||
{children(input) as React.ReactNode}
|
||||
</IsolatedBottomSheetScopeContext.Provider>
|
||||
)
|
||||
) : (
|
||||
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||
{children}
|
||||
</IsolatedBottomSheetScopeContext.Provider>
|
||||
);
|
||||
const { children, presentation = "push", ...bottomSheetProps } = props;
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
ref={ref}
|
||||
enableDismissOnClose
|
||||
stackBehavior={isNestedSheet ? "push" : "replace"}
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
{scopedChildren}
|
||||
{children}
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
if (isNestedSheet) {
|
||||
return modal;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal hostName="root">
|
||||
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
|
||||
</Portal>
|
||||
);
|
||||
return modal;
|
||||
});
|
||||
|
||||
export function useIsolatedBottomSheetVisibility({
|
||||
|
||||
@@ -70,17 +70,17 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(sheet.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("only reports a user close when the sheet was visible", () => {
|
||||
it("does not treat index -1 as a close because stacked sheets can be hidden without dismissing", () => {
|
||||
const { sheet, tracker, closeCount } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
expect(closeCount()).toBe(1);
|
||||
expect(closeCount()).toBe(0);
|
||||
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
expect(closeCount()).toBe(1);
|
||||
expect(closeCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a dismiss while visible as a close request", () => {
|
||||
@@ -93,7 +93,7 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(closeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("deduplicates close notifications from change and dismiss callbacks", () => {
|
||||
it("reports close once when a hidden sheet is actually dismissed", () => {
|
||||
const { sheet, tracker, closeCount } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
@@ -110,12 +110,14 @@ describe("bottom sheet visibility tracker", () => {
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
tracker.handleSheetDismiss();
|
||||
expect(closeCount()).toBe(1);
|
||||
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
tracker.handleSheetIndexChange(-1);
|
||||
tracker.handleSheetDismiss();
|
||||
expect(closeCount()).toBe(2);
|
||||
});
|
||||
|
||||
@@ -128,7 +130,7 @@ describe("bottom sheet visibility tracker", () => {
|
||||
tracker.attachController(null);
|
||||
tracker.attachController(sheet);
|
||||
|
||||
expect(closeCount()).toBe(1);
|
||||
expect(closeCount()).toBe(0);
|
||||
expect(sheet.events).toEqual([{ type: "present" }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
},
|
||||
handleSheetIndexChange(index) {
|
||||
if (index !== -1) {
|
||||
if (phase === "presenting") {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
phase = "presented";
|
||||
}
|
||||
return;
|
||||
@@ -77,9 +77,6 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
phase = "dismissing";
|
||||
}
|
||||
if (visible) {
|
||||
notifyClose();
|
||||
}
|
||||
},
|
||||
handleSheetDismiss() {
|
||||
if (visible) {
|
||||
|
||||
17
packages/app/src/components/ui/keyboard-dismiss.ts
Normal file
17
packages/app/src/components/ui/keyboard-dismiss.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { Keyboard } from "react-native";
|
||||
|
||||
export function useDismissKeyboardOnOpen(isOpen: boolean, enabled = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOpen) return;
|
||||
|
||||
Keyboard.dismiss();
|
||||
const frame = requestAnimationFrame(() => Keyboard.dismiss());
|
||||
const timer = setTimeout(() => Keyboard.dismiss(), 150);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, isOpen]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const { theme } = vi.hoisted(() => ({
|
||||
colors: {
|
||||
surface3: "#333",
|
||||
accent: "#0a84ff",
|
||||
accentForeground: "#fff",
|
||||
palette: { white: "#fff" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,6 +53,11 @@ export function Switch({
|
||||
}));
|
||||
|
||||
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[theme.colors.palette.white, theme.colors.accentForeground],
|
||||
),
|
||||
transform: [{ translateX: progress.value * thumbTravel }],
|
||||
}));
|
||||
|
||||
@@ -116,7 +121,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowRadius: 2,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||
|
||||
vi.hoisted(() => {
|
||||
@@ -20,18 +21,22 @@ function workspaceDescriptor(input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
projectDisplayName?: string;
|
||||
status?: WorkspaceDescriptor["status"];
|
||||
statusEnteredAt?: Date | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: `/repo/main/${input.id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: "done",
|
||||
status: input.status ?? "done",
|
||||
archivingAt: null,
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
@@ -56,6 +61,9 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupModeByServerId: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
@@ -95,6 +103,72 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("srv", "status");
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
[
|
||||
"ws-done",
|
||||
workspaceDescriptor({
|
||||
id: "ws-done",
|
||||
name: "Done",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
status: "done",
|
||||
statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"ws-running-old",
|
||||
workspaceDescriptor({
|
||||
id: "ws-running-old",
|
||||
name: "Running old",
|
||||
projectId: "project-2",
|
||||
projectDisplayName: "Project 2",
|
||||
status: "running",
|
||||
statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"ws-needs-input",
|
||||
workspaceDescriptor({
|
||||
id: "ws-needs-input",
|
||||
name: "Needs input",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
status: "needs_input",
|
||||
statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"ws-running-new",
|
||||
workspaceDescriptor({
|
||||
id: "ws-running-new",
|
||||
name: "Running new",
|
||||
projectId: "project-2",
|
||||
projectDisplayName: "Project 2",
|
||||
status: "running",
|
||||
statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "ws-needs-input" },
|
||||
{ serverId: "srv", workspaceId: "ws-running-new" },
|
||||
{ serverId: "srv", workspaceId: "ws-running-old" },
|
||||
{ serverId: "srv", workspaceId: "ws-done" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears targets when disabled", async () => {
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
useProjectNamesMap,
|
||||
useStatusModeWorkspaceEntries,
|
||||
} from "@/hooks/use-status-mode-workspaces";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
buildStatusSidebarShortcutModel,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
export function WorkspaceShortcutTargetsSubscriber({
|
||||
enabled,
|
||||
@@ -12,21 +20,45 @@ export function WorkspaceShortcutTargetsSubscriber({
|
||||
serverId: string | null;
|
||||
}) {
|
||||
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
||||
const statusWorkspaces = useStatusModeWorkspaceEntries({
|
||||
serverId: enabled ? serverId : null,
|
||||
projects,
|
||||
});
|
||||
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
enabled && serverId ? state.getGroupMode(serverId) : "project",
|
||||
);
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||
(state) => state.setSidebarShortcutWorkspaceTargets,
|
||||
);
|
||||
|
||||
const shortcutModel = useMemo(
|
||||
() =>
|
||||
buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys,
|
||||
}),
|
||||
[collapsedProjectKeys, projects],
|
||||
);
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (groupMode === "status") {
|
||||
return buildStatusSidebarShortcutModel({
|
||||
workspaces: statusWorkspaces,
|
||||
projectNamesByKey,
|
||||
collapsedStatusGroupKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys,
|
||||
});
|
||||
}, [
|
||||
collapsedProjectKeys,
|
||||
collapsedStatusGroupKeys,
|
||||
groupMode,
|
||||
projectNamesByKey,
|
||||
projects,
|
||||
statusWorkspaces,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !serverId) {
|
||||
|
||||
@@ -97,6 +97,7 @@ interface ControlledAgentControlsProps {
|
||||
/** Extra elements rendered inline with the agent controls (desktop only). */
|
||||
desktopExtras?: ReactNode;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface DraftAgentControlsProps {
|
||||
@@ -124,12 +125,14 @@ export interface DraftAgentControlsProps {
|
||||
isRetryingModelProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
interface AgentControlsProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
onDropdownClose?: () => void;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
function findOptionLabel(
|
||||
@@ -409,9 +412,11 @@ function ControlledAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
desktopExtras,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
|
||||
|
||||
@@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
onDropdownClose,
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
@@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({
|
||||
);
|
||||
|
||||
const modeChip = useMemo(
|
||||
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
|
||||
[serverId, agentId],
|
||||
() => (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="toolbar"
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[serverId, agentId, isCompactLayout],
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
@@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
disabled={!client}
|
||||
desktopExtras={modeChip}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1577,9 +1591,11 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
disabled = false,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: DraftAgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
|
||||
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
|
||||
return toThinkingControlOptions(thinkingOptions);
|
||||
@@ -1625,9 +1641,18 @@ export function DraftAgentControls({
|
||||
selectedMode={selectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
disabled={disabled}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
|
||||
[
|
||||
selectedProvider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
disabled,
|
||||
isCompactLayout,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isCompact) {
|
||||
@@ -1661,6 +1686,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
desktopExtras={draftModeChip}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1688,6 +1714,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,14 +235,17 @@ interface AgentModeControlProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export const AgentModeControl = memo(function AgentModeControl({
|
||||
serverId,
|
||||
agentId,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: AgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
@@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps {
|
||||
onSelectMode: (modeId: string) => void;
|
||||
disabled?: boolean;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export function DraftAgentModeControl({
|
||||
@@ -313,8 +317,10 @@ export function DraftAgentModeControl({
|
||||
onSelectMode,
|
||||
disabled,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: DraftAgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
if (!selectedProvider || modeOptions.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
return (
|
||||
|
||||
@@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
initialAttempt?: CreateAttempt | null;
|
||||
allowEmptyText?: boolean;
|
||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
|
||||
onCreateStart?: () => void;
|
||||
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
|
||||
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
|
||||
@@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
|
||||
onBeforeSubmit?.({
|
||||
await onBeforeSubmit?.({
|
||||
attempt,
|
||||
text: attempt.text,
|
||||
images: attempt.images,
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
@@ -36,7 +37,11 @@ import {
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
MAX_CONTENT_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
@@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
}, [pendingAutoSubmit, pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd: composerState.workingDir,
|
||||
@@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -432,8 +441,8 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
hasClient: Boolean(client),
|
||||
}),
|
||||
onBeforeSubmit: () => {
|
||||
void composerState.persistFormPreferences();
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
@@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact ? (
|
||||
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
|
||||
isCompactComposerLayout ? (
|
||||
<DraftAgentModeControl
|
||||
placement="footer"
|
||||
{...composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompact, composerAgentControls],
|
||||
[isCompactComposerLayout, composerAgentControls],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
@@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
@@ -140,6 +140,10 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
|
||||
return isWeb && !isMobile;
|
||||
}
|
||||
|
||||
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
@@ -223,14 +227,22 @@ interface RenderLeftContentArgs {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
const { agentControls, agentId, serverId, focusInput } = args;
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} />;
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
return (
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderAttachmentTrayArgs {
|
||||
@@ -674,6 +686,8 @@ interface ComposerProps {
|
||||
footer?: ReactNode;
|
||||
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
@@ -869,6 +883,7 @@ export function Composer({
|
||||
inputWrapperStyle,
|
||||
footer,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -899,9 +914,11 @@ export function Composer({
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const {
|
||||
@@ -1437,7 +1454,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isProcessing={isProcessing}
|
||||
isCompact={isMobile}
|
||||
isCompact={isCompactLayout}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1455,7 +1472,7 @@ export function Composer({
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isCompactLayout,
|
||||
isProcessing,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
@@ -1475,13 +1492,13 @@ export function Composer({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isMobile,
|
||||
isCompactLayout,
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isMobile],
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
|
||||
[contextWindowMeter, isMobile],
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
@@ -1548,8 +1565,8 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
|
||||
[agentId, focusInput, serverId, agentControls],
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1768,6 +1785,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: -theme.spacing[3],
|
||||
},
|
||||
alignItems: "center",
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
footerContent: {
|
||||
width: "100%",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user