mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00a940e74 | ||
|
|
c81c7bb7c0 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 | ||
|
|
89a022e853 | ||
|
|
14b25d4266 | ||
|
|
6cddc657cd | ||
|
|
14acb97662 | ||
|
|
4a4556f499 | ||
|
|
21d9bdc644 | ||
|
|
97cfcf8306 | ||
|
|
9603397aed | ||
|
|
309672c8e5 | ||
|
|
2ed1f4f353 | ||
|
|
cb6d2f1459 | ||
|
|
cf9dfcf947 | ||
|
|
7d10791bad | ||
|
|
2cc1886a6b | ||
|
|
d27c7ad590 | ||
|
|
a4d11cda2b | ||
|
|
c049e86fee | ||
|
|
5c93ac4aa5 | ||
|
|
9ca790df6a | ||
|
|
ee431bb340 | ||
|
|
0707092131 | ||
|
|
b2139b1400 | ||
|
|
a8fb40e689 | ||
|
|
25cdda9492 | ||
|
|
d6dc309408 | ||
|
|
6755341fbb | ||
|
|
2cb1b041dd | ||
|
|
5605b2aa94 | ||
|
|
fd4e13735c | ||
|
|
6acc82e9d3 | ||
|
|
d0456b1943 | ||
|
|
8aa55db1e8 | ||
|
|
afeb4d18f8 | ||
|
|
f214261ae5 | ||
|
|
aa6384babd | ||
|
|
ddb6d97bf0 | ||
|
|
187561e433 | ||
|
|
045de763f7 | ||
|
|
1962816c1e | ||
|
|
8c92c7d423 | ||
|
|
345d8e5240 | ||
|
|
4bda2dfea9 | ||
|
|
9292f58896 | ||
|
|
2ead7e7719 | ||
|
|
b3b1283d3b | ||
|
|
e1bda8e498 | ||
|
|
07d988488e | ||
|
|
0c68b26a8b | ||
|
|
3d86c738ff |
@@ -1,5 +1,65 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0-beta.3 - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
|
||||||
|
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
|
||||||
|
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||||
|
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
|
||||||
|
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
|
||||||
|
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
|
||||||
|
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
|
||||||
|
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
|
||||||
|
|
||||||
|
## 0.2.0-beta.2 - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
||||||
|
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||||
|
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||||
|
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
|
||||||
|
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||||
|
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||||
|
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||||
|
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||||
|
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||||
|
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||||
|
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||||
|
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||||
|
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
|
||||||
|
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
|
||||||
|
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
|
||||||
|
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
|
||||||
|
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
|
||||||
|
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
|
||||||
|
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
|
||||||
|
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
|
||||||
|
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
|
||||||
|
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
|
||||||
|
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
|
||||||
|
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||||
|
|
||||||
## 0.2.0-beta.1 - 2026-07-17
|
## 0.2.0-beta.1 - 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
|||||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||||
|
|
||||||
|
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||||
|
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||||
|
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||||
|
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
|
||||||
|
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
|
||||||
|
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||||
|
|
||||||
## Tabs vs archive
|
## Tabs vs archive
|
||||||
|
|
||||||
These are two distinct concepts that used to be conflated:
|
These are two distinct concepts that used to be conflated:
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
},
|
},
|
||||||
worktrees?: {
|
worktrees?: {
|
||||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||||
|
servicePorts?: { // optional dynamic service port allocation policy
|
||||||
|
range?: string // inclusive range, e.g. "3000-4000"
|
||||||
|
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
||||||
|
}
|
||||||
},
|
},
|
||||||
providers: {
|
providers: {
|
||||||
openai: {
|
openai: {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||||
|
|
||||||
|
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||||
|
|
||||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+16
-1
@@ -278,6 +278,15 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
||||||
|
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
||||||
|
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
||||||
|
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
||||||
|
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
||||||
|
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
||||||
|
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
||||||
|
so the port may already be bound. `portScript` takes precedence when both values are present.
|
||||||
|
|
||||||
## Bundled daemon web UI
|
## Bundled daemon web UI
|
||||||
|
|
||||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||||
@@ -374,13 +383,14 @@ install.
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cli -- ls -a -g # List all agents globally
|
npm run cli -- ls -a -g # List all agents globally
|
||||||
npm run cli -- ls -a -g --json # Same, as JSON
|
npm run cli -- ls -a -g --json # Same, as JSON
|
||||||
npm run cli -- inspect <id> # Show detailed agent info
|
npm run cli -- inspect <id> # Show detailed agent info
|
||||||
npm run cli -- logs <id> # View agent timeline
|
npm run cli -- logs <id> # View agent timeline
|
||||||
|
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||||
```
|
```
|
||||||
@@ -391,6 +401,11 @@ Use `--host <host:port>` to point the CLI at a different daemon:
|
|||||||
npm run cli -- --host localhost:7777 ls -a
|
npm run cli -- --host localhost:7777 ls -a
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Desktop integrations can focus an existing agent without creating one or
|
||||||
|
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||||
|
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||||
|
default; pass `--server <server-id>` when targeting another server.
|
||||||
|
|
||||||
## Agent state
|
## Agent state
|
||||||
|
|
||||||
Agent data lives at:
|
Agent data lives at:
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
|||||||
hidden workspace can overwrite the remembered workspace before Settings or
|
hidden workspace can overwrite the remembered workspace before Settings or
|
||||||
History returns.
|
History returns.
|
||||||
|
|
||||||
|
## Agent Targets
|
||||||
|
|
||||||
|
Notifications and agent URLs enter the router with different authoritative
|
||||||
|
targets.
|
||||||
|
|
||||||
|
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||||
|
directly to the workspace with the agent open intent.
|
||||||
|
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||||
|
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||||
|
the agent's workspace from the host, and then opens the agent there.
|
||||||
|
|
||||||
|
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||||
|
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
Required dynamic params belong to the matched route.
|
Required dynamic params belong to the matched route.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
|||||||
|
|
||||||
Two escape hatches in the codebase:
|
Two escape hatches in the codebase:
|
||||||
|
|
||||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||||
@@ -70,7 +70,13 @@ Two escape hatches in the codebase:
|
|||||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||||
cover them.
|
cover them.
|
||||||
|
|
||||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||||
|
|
||||||
|
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
||||||
|
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
||||||
|
ordinary portals regardless of `z-index`, which would hide app toasts and
|
||||||
|
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||||
|
lets tooltip portals paint above both.
|
||||||
|
|
||||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||||
|
|
||||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
|||||||
|
|
||||||
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 it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
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 it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||||
|
|
||||||
|
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||||
|
|
||||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||||
|
|
||||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||||
|
|||||||
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
|
|||||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||||
```
|
```
|
||||||
|
|
||||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||||
|
|
||||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||||
|
|||||||
+21
-8
@@ -51,17 +51,27 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
|||||||
|
|
||||||
When a client resumes without a cursor, it fetches the latest tail page.
|
When a client resumes without a cursor, it fetches the latest tail page.
|
||||||
|
|
||||||
|
## Client replica lifetime
|
||||||
|
|
||||||
|
The host runtime owns each session replica for as long as the host remains registered. React
|
||||||
|
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||||
|
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||||
|
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||||
|
|
||||||
|
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||||
|
session and host-scoped setup state together.
|
||||||
|
|
||||||
## Selective and legacy delivery
|
## Selective and legacy delivery
|
||||||
|
|
||||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||||
|
|
||||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||||
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||||
After grace has expired, a retained timeline stays covered when revisited until authoritative
|
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||||
catch-up completes; cached partial output is never presented as current history.
|
authoritative catch-up advances it to the current tail.
|
||||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||||
|
|
||||||
@@ -77,11 +87,14 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
|
|||||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||||
live prefix.
|
live prefix.
|
||||||
|
|
||||||
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
|
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||||
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
|
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||||
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
|
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||||
history. This keeps late history before a newly submitted prompt without duplicating an
|
instead of relocating visible user messages around newly fetched history.
|
||||||
acknowledged prompt.
|
|
||||||
|
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||||
|
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||||
|
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||||
|
|
||||||
## Relevant code
|
## Relevant code
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
|
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
|
||||||
|
|||||||
+8
-5
@@ -5,6 +5,7 @@
|
|||||||
nodejs_22,
|
nodejs_22,
|
||||||
python3,
|
python3,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
|
autoPatchelfHook,
|
||||||
# node-pty needs libuv headers on Linux
|
# node-pty needs libuv headers on Linux
|
||||||
libuv,
|
libuv,
|
||||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||||
@@ -59,10 +60,13 @@ buildNpmPackage rec {
|
|||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
python3 # for node-gyp (node-pty compilation)
|
python3 # for node-gyp (node-pty compilation)
|
||||||
makeWrapper
|
makeWrapper
|
||||||
|
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
autoPatchelfHook
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
libuv
|
libuv
|
||||||
|
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
|
||||||
];
|
];
|
||||||
|
|
||||||
# Don't use the default npm build hook — we need a custom build sequence
|
# Don't use the default npm build hook — we need a custom build sequence
|
||||||
@@ -71,10 +75,9 @@ buildNpmPackage rec {
|
|||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
||||||
# Rebuild only node-pty (native addon for terminal emulation).
|
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
|
||||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
# speech runtime ships prebuilt platform packages and is copied into the
|
||||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
# daemon closure by scripts/trace-daemon.mjs.
|
||||||
# degrade when unavailable.
|
|
||||||
npm rebuild node-pty
|
npm rebuild node-pty
|
||||||
|
|
||||||
# Build all server packages in dependency order (defined in package.json)
|
# Build all server packages in dependency order (defined in package.json)
|
||||||
@@ -89,7 +92,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# Compute the daemon's runtime closure by static module-graph tracing
|
# Compute the daemon's runtime closure by static module-graph tracing
|
||||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
# forked terminal/speech worker processes) plus an explicit list of non-JS
|
||||||
# assets read at runtime. The trace script is the single source of
|
# assets read at runtime. The trace script is the single source of
|
||||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||||
# npm hoisting / .bin / workspace-symlink footguns.
|
# npm hoisting / .bin / workspace-symlink footguns.
|
||||||
|
|||||||
Generated
+87
-32
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -2531,10 +2531,35 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||||
|
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||||
|
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/language": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.12.3",
|
"version": "6.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -2554,22 +2579,33 @@
|
|||||||
"@codemirror/language": "^6.0.0"
|
"@codemirror/language": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||||
|
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/state": {
|
"node_modules/@codemirror/state": {
|
||||||
"version": "6.6.0",
|
"version": "6.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.0",
|
"version": "6.43.6",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
"style-mod": "^4.1.0",
|
"style-mod": "^4.1.0",
|
||||||
"w3c-keyname": "^2.2.4"
|
"w3c-keyname": "^2.2.4"
|
||||||
@@ -11055,6 +11091,19 @@
|
|||||||
"@lezer/lr": "^1.0.0"
|
"@lezer/lr": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@replit/codemirror-vim": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@codemirror/commands": "6.x.x",
|
||||||
|
"@codemirror/language": "6.x.x",
|
||||||
|
"@codemirror/search": "6.x.x",
|
||||||
|
"@codemirror/state": "6.x.x",
|
||||||
|
"@codemirror/view": "6.x.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/pluginutils": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
@@ -35162,8 +35211,13 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.10.4",
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/search": "6.7.1",
|
||||||
|
"@codemirror/state": "6.7.1",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -35178,6 +35232,7 @@
|
|||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@replit/codemirror-vim": "6.3.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
@@ -36181,12 +36236,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.2.0-beta.1",
|
"@getpaseo/client": "0.2.0-beta.3",
|
||||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/server": "0.2.0-beta.1",
|
"@getpaseo/server": "0.2.0-beta.3",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36432,10 +36487,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/relay": "0.2.0-beta.1",
|
"@getpaseo/relay": "0.2.0-beta.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36446,7 +36501,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36689,7 +36744,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37585,9 +37640,9 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -37817,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -37830,7 +37885,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38048,15 +38103,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.2.0-beta.1",
|
"@getpaseo/client": "0.2.0-beta.3",
|
||||||
"@getpaseo/highlight": "0.2.0-beta.1",
|
"@getpaseo/highlight": "0.2.0-beta.3",
|
||||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/relay": "0.2.0-beta.1",
|
"@getpaseo/relay": "0.2.0-beta.3",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38593,7 +38648,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -132,6 +132,8 @@
|
|||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||||
|
|
||||||
|
function visibleComposer(page: Page) {
|
||||||
|
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "add-file-to-chat-",
|
||||||
|
title: "Target chat",
|
||||||
|
});
|
||||||
|
const relativePath = "src/changed file.ts";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||||
|
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentComposer = visibleComposer(page);
|
||||||
|
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||||
|
await agentComposer.fill("Preserve this thought");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||||
|
await page.getByTestId("explorer-tab-changes").click();
|
||||||
|
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||||
|
|
||||||
|
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||||
|
await expect(attachment).toContainText("changed file.ts");
|
||||||
|
await expect(attachment).toContainText(relativePath);
|
||||||
|
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||||
|
await expect(agentComposer).toBeFocused();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mkdtempSync, realpathSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { openSessions } from "./helpers/archive-tab";
|
||||||
|
import {
|
||||||
|
assertChatTranscript,
|
||||||
|
cleanupRewindFlow,
|
||||||
|
launchAgent,
|
||||||
|
sendMessage,
|
||||||
|
type AgentHandle,
|
||||||
|
} from "./helpers/rewind-flow";
|
||||||
|
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
interface TimelineClient extends SeedDaemonClient {
|
||||||
|
fetchAgentTimeline(
|
||||||
|
agentId: string,
|
||||||
|
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||||
|
): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||||
|
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||||
|
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||||
|
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||||
|
|
||||||
|
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||||
|
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||||
|
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("archived Codex agent recovery", () => {
|
||||||
|
test.setTimeout(600_000);
|
||||||
|
|
||||||
|
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||||
|
let handle: AgentHandle | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handle = await launchAgent({
|
||||||
|
page,
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
mode: "full-access",
|
||||||
|
});
|
||||||
|
await sendMessage(handle, INITIAL_PROMPT);
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await handle.client.archiveAgent(handle.agentId);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const timelineClient = handle.client as TimelineClient;
|
||||||
|
await expect(
|
||||||
|
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||||
|
direction: "tail",
|
||||||
|
projection: "projected",
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/archiv/i);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||||
|
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await cleanupRewindFlow({ handle, cwd });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
|
|
||||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||||
"aria-selected",
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await panel.getByTestId("commit-diff-layout-split").click();
|
await layoutToggle.click();
|
||||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||||
await expect(panel).toHaveCount(0);
|
await expect(panel).toHaveCount(0);
|
||||||
await commitRow.click();
|
await commitRow.click();
|
||||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||||
"aria-selected",
|
"Switch to unified diff",
|
||||||
"true",
|
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { writeFile } from "node:fs/promises";
|
import { unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Page } from "@playwright/test";
|
import { type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||||
@@ -10,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
|||||||
|
|
||||||
interface DirtyWorkspace {
|
interface DirtyWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
|
repoPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFixtureOptions {
|
||||||
|
includeDeletedFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CleanupTask {
|
interface CleanupTask {
|
||||||
@@ -218,6 +223,99 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
|
await page.getByTestId("diff-file-1-actions").click();
|
||||||
|
await expect(page.getByText("Copy path")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||||
|
await page.getByTestId("diff-file-0-open-file").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||||
|
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||||
|
await changesTabToggle.click();
|
||||||
|
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||||
|
|
||||||
|
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
await expect(visiblePanel).toBeVisible();
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||||
|
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
|
await workingDiffLayoutToggle.click();
|
||||||
|
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||||
|
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||||
|
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||||
|
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||||
|
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||||
|
"Scroll long lines",
|
||||||
|
);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||||
|
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||||
|
"zz-deleted.ts",
|
||||||
|
);
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||||
|
await expect(visiblePanel).toContainText("Deleted");
|
||||||
|
|
||||||
|
await changesTabToggle.click();
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -243,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Expand all" }).click();
|
||||||
|
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("diff-folder-src-toggle").click();
|
await page.getByTestId("diff-folder-src-toggle").click();
|
||||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -250,6 +353,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await expectFlatFileList(page);
|
await expectFlatFileList(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
|
|
||||||
|
const changesRightRail = await Promise.all([
|
||||||
|
readSvgRight(page, "explorer-close"),
|
||||||
|
readSvgRight(page, "changes-options-menu"),
|
||||||
|
readSvgRight(page, "diff-file-0-actions"),
|
||||||
|
]);
|
||||||
|
expectAligned(changesRightRail);
|
||||||
|
|
||||||
|
const [folderStat, fileStat] = await Promise.all([
|
||||||
|
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||||
|
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(folderStat).not.toBeNull();
|
||||||
|
expect(fileStat).not.toBeNull();
|
||||||
|
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||||
|
|
||||||
|
await page.getByTestId("explorer-tab-files").click();
|
||||||
|
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||||
|
|
||||||
|
const filesRightRail = await Promise.all([
|
||||||
|
readSvgRight(page, "explorer-close"),
|
||||||
|
readSvgRight(page, "files-refresh"),
|
||||||
|
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||||
|
]);
|
||||||
|
expectAligned(filesRightRail);
|
||||||
|
|
||||||
|
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||||
|
page.getByTestId("files-sort-label").boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(sortLabel).not.toBeNull();
|
||||||
|
expect(firstRowIcon).not.toBeNull();
|
||||||
|
expect(treeBounds).not.toBeNull();
|
||||||
|
expect(rowBounds).not.toBeNull();
|
||||||
|
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||||
|
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||||
|
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
async function createWorkspaceWithMountedTabDiff(
|
||||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
options: WorkspaceFixtureOptions = {},
|
||||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
): Promise<DirtyWorkspace> {
|
||||||
});
|
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||||
|
if (options.includeDeletedFile) {
|
||||||
|
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||||
|
}
|
||||||
|
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
cleanupTasks.push({
|
cleanupTasks.push({
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
|
if (options.includeDeletedFile) {
|
||||||
|
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||||
|
}
|
||||||
const createdWorkspace = await client.createWorkspace({
|
const createdWorkspace = await client.createWorkspace({
|
||||||
source: { kind: "directory", path: repo.path },
|
source: { kind: "directory", path: repo.path },
|
||||||
});
|
});
|
||||||
if (!createdWorkspace.workspace) {
|
if (!createdWorkspace.workspace) {
|
||||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||||
}
|
}
|
||||||
return { id: createdWorkspace.workspace.id };
|
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
@@ -436,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
|||||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||||
|
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error(`Could not measure ${testID}`);
|
||||||
|
}
|
||||||
|
return box.x + box.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectAligned(values: number[]): void {
|
||||||
|
const [first, ...rest] = values;
|
||||||
|
for (const value of rest) {
|
||||||
|
expect(value).toBeCloseTo(first, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "./fixtures";
|
||||||
|
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||||
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
|
|
||||||
|
const RED_PIXEL = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const BLUE_PIXEL = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
function editor(page: Page) {
|
||||||
|
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||||
|
const contentElement = editor(page);
|
||||||
|
await contentElement.click();
|
||||||
|
await contentElement.press("Control+A");
|
||||||
|
await contentElement.type(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||||
|
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||||
|
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, filename);
|
||||||
|
await expectFileTabOpen(page, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAgentWithFileLink(target: string) {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-editing-chat-link-",
|
||||||
|
title: "Chat file link e2e",
|
||||||
|
initialPrompt: [
|
||||||
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||||
|
"Return JSON only with fields 'title' and 'branch'.",
|
||||||
|
"",
|
||||||
|
"<user-prompt>",
|
||||||
|
`Open \`${target}\` now`,
|
||||||
|
"</user-prompt>",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "target.ts"),
|
||||||
|
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
|
const target = "target.ts:42";
|
||||||
|
const session = await seedAgentWithFileLink(target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
|
||||||
|
const fileLink = page.getByText(target, { exact: true });
|
||||||
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||||
|
await fileLink.click();
|
||||||
|
|
||||||
|
await expectFileTabOpen(page, "target.ts");
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const sourceEditor = editor(page);
|
||||||
|
await sourceEditor.click();
|
||||||
|
await sourceEditor.press("Control+Home");
|
||||||
|
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await expect(fileLink).toBeVisible();
|
||||||
|
await fileLink.click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||||
|
).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the full file path and keeps editor controls stable", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||||
|
const relativePath = "src/deep/visuals.md";
|
||||||
|
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||||
|
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
sourcePath,
|
||||||
|
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||||
|
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||||
|
await openFileFromExplorer(page, "visuals.md");
|
||||||
|
await expectFileTabOpen(page, relativePath);
|
||||||
|
|
||||||
|
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||||
|
await fileTab.hover();
|
||||||
|
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||||
|
relativePath,
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||||
|
const modeControl = page.getByTestId("file-markdown-mode");
|
||||||
|
await expect(modeControl).toBeVisible();
|
||||||
|
await page.getByTestId("file-mode-source").click();
|
||||||
|
|
||||||
|
const editorHost = page.getByTestId("file-source-editor");
|
||||||
|
const content = editor(page);
|
||||||
|
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||||
|
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||||
|
|
||||||
|
await content.click();
|
||||||
|
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||||
|
await expect(cursor).toBeVisible();
|
||||||
|
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||||
|
|
||||||
|
const initialModeBox = await modeControl.boundingBox();
|
||||||
|
expect(initialModeBox).not.toBeNull();
|
||||||
|
const initialModeX = initialModeBox!.x;
|
||||||
|
await content.press("Control+End");
|
||||||
|
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||||
|
const movedModeBox = await modeControl.boundingBox();
|
||||||
|
expect(movedModeBox).not.toBeNull();
|
||||||
|
expect(movedModeBox!.x).toBe(initialModeX);
|
||||||
|
|
||||||
|
await content.press("Control+a");
|
||||||
|
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||||
|
await expect(selection).toBeVisible();
|
||||||
|
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
||||||
|
});
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
||||||
|
const relativePath = "tooltip-font.txt";
|
||||||
|
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, relativePath);
|
||||||
|
await expectFileTabOpen(page, relativePath);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
||||||
|
.getByText(relativePath, { exact: true }),
|
||||||
|
).toHaveCSS("font-family", "monospace");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||||
|
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||||
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||||
|
await Promise.all(
|
||||||
|
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||||
|
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "source.ts");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const immediate = 3;\n");
|
||||||
|
await editor(page).press("Control+s");
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||||
|
|
||||||
|
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||||
|
await expect(editor(page)).toContainText("const external = 4;");
|
||||||
|
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const localWins = 5;\n");
|
||||||
|
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||||
|
await openWorkspaceFile(page, fileName);
|
||||||
|
}
|
||||||
|
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||||
|
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const discarded = 7;\n");
|
||||||
|
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||||
|
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||||
|
|
||||||
|
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||||
|
await gate.drop();
|
||||||
|
gate.restore();
|
||||||
|
await expect
|
||||||
|
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||||
|
.toBeGreaterThan(subscriptionCount);
|
||||||
|
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||||
|
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||||
|
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||||
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "draft.ts");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const local = 2;\n");
|
||||||
|
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||||
|
|
||||||
|
let closePrompt = "";
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
closePrompt = dialog.message();
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-tab-file_draft.ts")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click({ button: "right" });
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||||
|
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||||
|
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||||
|
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||||
|
await writeFile(imagePath, RED_PIXEL);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "notes.md");
|
||||||
|
|
||||||
|
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||||
|
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||||
|
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("file-mode-source").click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await replaceEditorText(page, "# Saved from source\n");
|
||||||
|
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||||
|
await page.getByTestId("file-mode-preview").click();
|
||||||
|
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await openWorkspaceFile(page, "pixel.png");
|
||||||
|
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
const initialSource = await image.getAttribute("src");
|
||||||
|
await writeFile(imagePath, BLUE_PIXEL);
|
||||||
|
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||||
|
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||||
|
|
||||||
|
await page.goto("/settings/editor");
|
||||||
|
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toBeChecked();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||||
|
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "vim.ts");
|
||||||
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||||
|
await editor(page).click();
|
||||||
|
await editor(page).press("i");
|
||||||
|
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||||
|
await editor(page).press("Escape");
|
||||||
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -328,6 +328,12 @@ interface PairingDaemonClient {
|
|||||||
|
|
||||||
async function createFakeEditorBin(): Promise<string> {
|
async function createFakeEditorBin(): Promise<string> {
|
||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||||
|
let realGhPath = "";
|
||||||
|
try {
|
||||||
|
realGhPath = execSync("which gh").toString().trim();
|
||||||
|
} catch {
|
||||||
|
// The local PR fixture below remains usable without a system gh binary.
|
||||||
|
}
|
||||||
|
|
||||||
const fakeEditorSource = `#!/usr/bin/env node
|
const fakeEditorSource = `#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -349,6 +355,70 @@ if (recordPath) {
|
|||||||
await chmod(editorPath, 0o755);
|
await chmod(editorPath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fakeGhPath = path.join(binDir, "gh");
|
||||||
|
const fakeGhSource = `#!/usr/bin/env node
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||||
|
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"]
|
||||||
|
}).stdout?.trim();
|
||||||
|
|
||||||
|
if (origin === fixtureRemote) {
|
||||||
|
const command = args.slice(0, 2).join(" ");
|
||||||
|
if (command === "auth status") process.exit(0);
|
||||||
|
if (command === "repo view") {
|
||||||
|
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "issue list") {
|
||||||
|
process.stdout.write("[]");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "pr list" || command === "pr view") {
|
||||||
|
const pr = {
|
||||||
|
number: 1,
|
||||||
|
title: "Use pasted PR as start ref",
|
||||||
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||||
|
state: "OPEN",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "pr-branch-1",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z"
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
data: { repository: { pullRequest: {
|
||||||
|
number: 1,
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "pr-branch-1",
|
||||||
|
isCrossRepository: false,
|
||||||
|
headRepositoryOwner: { login: "paseo-e2e" },
|
||||||
|
headRepository: {
|
||||||
|
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||||
|
url: fixtureRemote
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
}));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||||
|
if (!realGhPath) process.exit(127);
|
||||||
|
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
`;
|
||||||
|
await writeFile(fakeGhPath, fakeGhSource);
|
||||||
|
await chmod(fakeGhPath, 0o755);
|
||||||
|
|
||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
|
|||||||
daemonListen?: string;
|
daemonListen?: string;
|
||||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||||
hangDaemonStart?: boolean;
|
hangDaemonStart?: boolean;
|
||||||
|
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||||
|
desktopSettingsDelayMs?: number;
|
||||||
/**
|
/**
|
||||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||||
@@ -101,6 +103,7 @@ declare global {
|
|||||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||||
|
__desktopDaemonStartRequested?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
let daemonRunning = true;
|
let daemonRunning = true;
|
||||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||||
let startCount = 0;
|
let startCount = 0;
|
||||||
|
window.__desktopDaemonStartRequested = false;
|
||||||
|
|
||||||
function buildDaemonStatus() {
|
function buildDaemonStatus() {
|
||||||
return {
|
return {
|
||||||
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDesktopDaemon() {
|
function startDesktopDaemon() {
|
||||||
|
window.__desktopDaemonStartRequested = true;
|
||||||
if (cfg.hangDaemonStart) {
|
if (cfg.hangDaemonStart) {
|
||||||
return new Promise(() => undefined);
|
return new Promise(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
return buildDaemonStatus();
|
return buildDaemonStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForDesktopSettingsResponse() {
|
||||||
|
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const desktopBridge: {
|
const desktopBridge: {
|
||||||
platform: string;
|
platform: string;
|
||||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "get_desktop_settings") {
|
if (command === "get_desktop_settings") {
|
||||||
|
await waitForDesktopSettingsResponse();
|
||||||
return {
|
return {
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}, config);
|
}, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||||
|
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||||
|
// Give the startup state two paints to expose any app → splash regression.
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForDirectoryDialog(
|
export async function waitForDirectoryDialog(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<Record<string, unknown> | undefined> {
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export function hasGithubAuth(): boolean {
|
export function hasGithubAuth(): boolean {
|
||||||
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
|
|||||||
cleanup(): Promise<void>;
|
cleanup(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalGhPrFixture {
|
||||||
|
pr: GhPrFixture;
|
||||||
|
mainCheckout: GhDefaultBranchClone;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||||
return execFileSync("gh", args, {
|
return execFileSync("gh", args, {
|
||||||
cwd: opts?.cwd,
|
cwd: opts?.cwd,
|
||||||
@@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||||
|
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||||
|
const basePath = path.join(fixtureRoot, "base");
|
||||||
|
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||||
|
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||||
|
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||||
|
await mkdir(basePath);
|
||||||
|
|
||||||
|
git(["init", "-b", "main"], basePath);
|
||||||
|
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||||
|
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||||
|
git(["config", "commit.gpgsign", "false"], basePath);
|
||||||
|
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||||
|
git(["add", "README.md"], basePath);
|
||||||
|
git(["commit", "-m", "Initial commit"], basePath);
|
||||||
|
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||||
|
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||||
|
git(["add", "pr-1.txt"], basePath);
|
||||||
|
git(["commit", "-m", "Add PR 1"], basePath);
|
||||||
|
git(["checkout", "main"], basePath);
|
||||||
|
|
||||||
|
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||||
|
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||||
|
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||||
|
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||||
|
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pr: {
|
||||||
|
number: 1,
|
||||||
|
title: "Use pasted PR as start ref",
|
||||||
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||||
|
branch: "pr-branch-1",
|
||||||
|
localPath: basePath,
|
||||||
|
},
|
||||||
|
mainCheckout: {
|
||||||
|
path: checkoutPath,
|
||||||
|
cleanup: async () => {},
|
||||||
|
},
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
import { connectDaemonClient } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
@@ -290,6 +290,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
|||||||
await branchRow.click();
|
await branchRow.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||||
|
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||||
|
await searchInput.fill(name);
|
||||||
|
await selectBranchInPicker(page, name);
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -351,6 +358,18 @@ export async function expectComposerGithubAttachmentPill(
|
|||||||
await expect(pills.first()).toContainText(input.title);
|
await expect(pills.first()).toContainText(input.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pasteGithubPrUrl(
|
||||||
|
page: Page,
|
||||||
|
context: BrowserContext,
|
||||||
|
url: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||||
|
await composer.focus();
|
||||||
|
await page.keyboard.press("Control+V");
|
||||||
|
}
|
||||||
|
|
||||||
export async function assertNewWorkspaceSidebarAndHeader(
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
|||||||
} | null;
|
} | null;
|
||||||
fetchAgentHistory(options?: {
|
fetchAgentHistory(options?: {
|
||||||
page?: { limit: number };
|
page?: { limit: number };
|
||||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
|||||||
const SECTION_LABELS = {
|
const SECTION_LABELS = {
|
||||||
general: "General",
|
general: "General",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
|
editor: "Editor",
|
||||||
shortcuts: "Shortcuts",
|
shortcuts: "Shortcuts",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ interface SessionMessage {
|
|||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSubscriptionWaitOptions {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||||
if (typeof message !== "string") return null;
|
if (typeof message !== "string") return null;
|
||||||
try {
|
try {
|
||||||
@@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
async waitForSubscribedAgents(
|
||||||
|
agentIds: string[],
|
||||||
|
options: TimelineSubscriptionWaitOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
const expected = [...new Set(agentIds)].sort();
|
const expected = [...new Set(agentIds)].sort();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||||
|
timeout: options.timeout ?? 15_000,
|
||||||
|
})
|
||||||
.toEqual(expected);
|
.toEqual(expected);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantFrameState {
|
|
||||||
active: boolean;
|
|
||||||
lastText: string | null;
|
|
||||||
snapshots: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function observeLastAssistantFrames(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const state: AssistantFrameState = {
|
|
||||||
active: true,
|
|
||||||
lastText: null,
|
|
||||||
snapshots: [],
|
|
||||||
};
|
|
||||||
const sample = () => {
|
|
||||||
const hasPaintedHistoryOverlay = Array.from(
|
|
||||||
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
|
|
||||||
).some((element) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
if (!hasPaintedHistoryOverlay) {
|
|
||||||
const messages = Array.from(
|
|
||||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
|
||||||
).filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
const text = messages.at(-1)?.textContent?.trim() ?? "";
|
|
||||||
if (text && text !== state.lastText) {
|
|
||||||
state.lastText = text;
|
|
||||||
state.snapshots.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.active) requestAnimationFrame(sample);
|
|
||||||
};
|
|
||||||
Object.assign(window, { __assistantFrameState: state });
|
|
||||||
requestAnimationFrame(sample);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
async stop(): Promise<string[]> {
|
|
||||||
return page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise<string[]>((resolve) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const state = (
|
|
||||||
window as typeof window & { __assistantFrameState?: AssistantFrameState }
|
|
||||||
).__assistantFrameState;
|
|
||||||
if (!state) {
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.active = false;
|
|
||||||
resolve([...state.snapshots]);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import {
|
|||||||
expectPickerOpen,
|
expectPickerOpen,
|
||||||
expectPickerSelected,
|
expectPickerSelected,
|
||||||
expectStartingRefPickerTriggerPr,
|
expectStartingRefPickerTriggerPr,
|
||||||
|
fillNewWorkspaceDraft,
|
||||||
openGlobalNewWorkspaceComposer,
|
openGlobalNewWorkspaceComposer,
|
||||||
openBranchPicker,
|
openBranchPicker,
|
||||||
openNewWorkspaceComposer,
|
openNewWorkspaceComposer,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
|
pasteGithubPrUrl,
|
||||||
|
searchAndSelectBranchInPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
selectGitHubPrInPicker,
|
selectGitHubPrInPicker,
|
||||||
selectPickerOptionByKeyboard,
|
selectPickerOptionByKeyboard,
|
||||||
@@ -30,9 +33,11 @@ import {
|
|||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
|
createLocalGithubPrFixture,
|
||||||
cloneGithubRepoDefaultBranchOnly,
|
cloneGithubRepoDefaultBranchOnly,
|
||||||
createTempGithubRepo,
|
createTempGithubRepo,
|
||||||
hasGithubAuth,
|
hasGithubAuth,
|
||||||
|
type LocalGhPrFixture,
|
||||||
} from "./helpers/github-fixtures";
|
} from "./helpers/github-fixtures";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
|
|||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
const createdWorktreeDirectories = new Set<string>();
|
const createdWorktreeDirectories = new Set<string>();
|
||||||
|
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||||
|
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
|
|||||||
await client?.close().catch(() => undefined);
|
await client?.close().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
for (const fixture of localGithubFixtures) {
|
||||||
|
await fixture.cleanup();
|
||||||
|
}
|
||||||
|
localGithubFixtures.clear();
|
||||||
|
});
|
||||||
|
|
||||||
test("adds a project from the selected empty host", async ({ page }) => {
|
test("adds a project from the selected empty host", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||||
const primaryServerId = getServerId();
|
const primaryServerId = getServerId();
|
||||||
@@ -807,6 +820,100 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const fixture = await createLocalGithubPrFixture();
|
||||||
|
localGithubFixtures.add(fixture);
|
||||||
|
const { pr, mainCheckout } = fixture;
|
||||||
|
|
||||||
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await selectBranchInPicker(page, "main");
|
||||||
|
|
||||||
|
await pasteGithubPrUrl(page, context, pr.url);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||||
|
await expectComposerGithubAttachmentPill(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
});
|
||||||
|
await expectStartingRefPickerTriggerPr(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
headRef: pr.branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitNewWorkspaceWithoutPrompt(page);
|
||||||
|
|
||||||
|
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
client,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
|
|
||||||
|
const branchInfo = await readWorktreeBranchInfo({
|
||||||
|
worktreePath: worktree.workspaceDirectory,
|
||||||
|
});
|
||||||
|
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||||
|
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const fixture = await createLocalGithubPrFixture();
|
||||||
|
localGithubFixtures.add(fixture);
|
||||||
|
const { pr, mainCheckout } = fixture;
|
||||||
|
|
||||||
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
|
await pasteGithubPrUrl(page, context, pr.url);
|
||||||
|
await expectStartingRefPickerTriggerPr(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
headRef: pr.branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await searchAndSelectBranchInPicker(page, "main");
|
||||||
|
await expectPickerSelected(page, "main");
|
||||||
|
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||||
|
await expectPickerSelected(page, "main");
|
||||||
|
await submitNewWorkspaceWithoutPrompt(page);
|
||||||
|
|
||||||
|
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
client,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
|
|
||||||
|
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||||
|
|
||||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||||
observeLastAssistantFrames,
|
|
||||||
observeTimelineSubscriptions,
|
|
||||||
} from "./helpers/timeline-delivery";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
import {
|
import {
|
||||||
@@ -75,47 +72,40 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Viewed agent timelines", () => {
|
test.describe("Viewed agent timelines", () => {
|
||||||
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const subscriptions = observeTimelineSubscriptions(page);
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
try {
|
try {
|
||||||
await openAgent(page, scenario, scenario.firstAgentId);
|
await openAgent(page, scenario, scenario.firstAgentId);
|
||||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
|
||||||
|
|
||||||
await scenario.client.sendAgentMessage(
|
|
||||||
scenario.firstAgentId,
|
|
||||||
"Stream while focused, then finish while hidden.",
|
|
||||||
);
|
|
||||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
|
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
await commitMessage(
|
||||||
expect(finish.status).toBe("idle");
|
scenario,
|
||||||
|
scenario.firstAgentId,
|
||||||
const assistantFrames = await observeLastAssistantFrames(page);
|
"Committed after the first chat unsubscribed.",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
await selectAgent(page, "First viewed chat");
|
await selectAgent(page, "First viewed chat");
|
||||||
|
await expect(
|
||||||
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||||
const snapshots = await assistantFrames.stop();
|
|
||||||
|
|
||||||
expect(snapshots[0]).toContain("(end of synthetic stream)");
|
|
||||||
} finally {
|
} finally {
|
||||||
await scenario.cleanup();
|
await scenario.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
try {
|
try {
|
||||||
await openAgent(page, scenario, scenario.firstAgentId);
|
await openAgent(page, scenario, scenario.firstAgentId);
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
|
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||||
await commitMessage(
|
await commitMessage(
|
||||||
scenario,
|
scenario,
|
||||||
scenario.firstAgentId,
|
scenario.firstAgentId,
|
||||||
@@ -128,6 +118,7 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await scenario.cleanup();
|
await scenario.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||||
|
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
@@ -43,6 +44,49 @@ async function fetchActiveAgentTitle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Workspace agent title handoff", () => {
|
test.describe("Workspace agent title handoff", () => {
|
||||||
|
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
|
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
|
||||||
|
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||||
|
await submitMessage(page, prompt);
|
||||||
|
const agentId = await timelineGate.waitForCreatedAgent();
|
||||||
|
await timelineGate.waitForDelayedResponse();
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
|
||||||
|
|
||||||
|
const overlayAppeared = page
|
||||||
|
.getByTestId("agent-history-overlay")
|
||||||
|
.waitFor({ state: "attached", timeout: 2_000 })
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
timelineGate.release();
|
||||||
|
await timelineGate.waitForForwardedResponse();
|
||||||
|
|
||||||
|
expect(await overlayAppeared).toBe(false);
|
||||||
|
} finally {
|
||||||
|
timelineGate.release();
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostWorkspaceOpenRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
@@ -31,11 +35,53 @@ import {
|
|||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||||
import { expectAppRoute } from "./helpers/route-assertions";
|
import { expectAppRoute } from "./helpers/route-assertions";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
|
type StartupPresentation = "splash" | "app";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const trace: StartupPresentation[] = [];
|
||||||
|
window.__paseoStartupPresentationTrace = trace;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const recordPresentation = () => {
|
||||||
|
let presentation: StartupPresentation | null = null;
|
||||||
|
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||||
|
presentation = "splash";
|
||||||
|
} else if (
|
||||||
|
document.querySelector(
|
||||||
|
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
presentation = "app";
|
||||||
|
}
|
||||||
|
if (presentation && trace.at(-1) !== presentation) {
|
||||||
|
trace.push(presentation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
recordPresentation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||||
|
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectNoLoadingWorkspacePane(
|
async function expectNoLoadingWorkspacePane(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -117,6 +163,43 @@ async function expectWorkspaceLocation(
|
|||||||
test.describe("Workspace navigation regression", () => {
|
test.describe("Workspace navigation regression", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||||
|
const target = {
|
||||||
|
serverId: "notification-offline-host",
|
||||||
|
workspaceId: "notification-workspace",
|
||||||
|
agentId: "notification-agent",
|
||||||
|
};
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await addOfflineHostAndReload(page, {
|
||||||
|
serverId: target.serverId,
|
||||||
|
label: "Notification Host",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate((data) => {
|
||||||
|
globalThis.dispatchEvent(
|
||||||
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
|
detail: { data: { ...data, reason: "finished" } },
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, target);
|
||||||
|
|
||||||
|
await expectAppRoute(
|
||||||
|
page,
|
||||||
|
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
@@ -239,7 +322,9 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const daemonGate = await installDaemonWebSocketGate(page);
|
const daemonGate = await installDaemonWebSocketGate(page);
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||||
@@ -254,6 +339,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
serverId,
|
serverId,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
hangDaemonStart: true,
|
hangDaemonStart: true,
|
||||||
|
desktopSettingsDelayMs: 250,
|
||||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
});
|
});
|
||||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||||
@@ -261,14 +347,16 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
|
|
||||||
|
await observeStartupPresentation(page);
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
await waitForDesktopDaemonStartRequest(page);
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||||
} finally {
|
} finally {
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
@@ -374,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceDeckEntryCount(page, 2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ agentId, serverId: targetServerId }) => {
|
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||||
globalThis.dispatchEvent(
|
globalThis.dispatchEvent(
|
||||||
new CustomEvent("paseo:web-notification-click", {
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
detail: {
|
detail: {
|
||||||
data: {
|
data: {
|
||||||
serverId: targetServerId,
|
serverId: targetServerId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
reason: "finished",
|
reason: "finished",
|
||||||
},
|
},
|
||||||
@@ -388,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ agentId: secondAgent.id, serverId },
|
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||||
);
|
);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
|||||||
tempRepo = await createTempGitRepo("wt-restore-");
|
tempRepo = await createTempGitRepo("wt-restore-");
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createArchivedMissingWorktree(prefix: string) {
|
async function createArchivedMissingWorktree(
|
||||||
|
prefix: string,
|
||||||
|
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||||
|
) {
|
||||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||||
createdProjectIds.add(project.projectKey);
|
createdProjectIds.add(project.projectKey);
|
||||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
|||||||
});
|
});
|
||||||
createdProjectIds.add(worktree.projectKey);
|
createdProjectIds.add(worktree.projectKey);
|
||||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
const agent = await createIdleAgent(client, {
|
const agents = await Promise.all(
|
||||||
cwd: worktree.workspaceDirectory,
|
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||||
workspaceId: worktree.workspaceId,
|
createIdleAgent(client, {
|
||||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
cwd: worktree.workspaceDirectory,
|
||||||
});
|
workspaceId: worktree.workspaceId,
|
||||||
|
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const agent = agents[0];
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error("Expected at least one archived-worktree agent");
|
||||||
|
}
|
||||||
|
|
||||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||||
await expect
|
await expect
|
||||||
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
|
|||||||
// Match the remote cloud-race record: workspace archived and absent, while
|
// Match the remote cloud-race record: workspace archived and absent, while
|
||||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
if (options.keepAgentsArchived) {
|
||||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
for (const archivedAgent of agents) {
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||||
|
.not.toBeNull();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
}
|
||||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||||
|
|
||||||
return { agent, worktree };
|
return { agent, agents, worktree };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||||
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
|
|||||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||||
|
agentCount: 2,
|
||||||
|
keepAgentsArchived: true,
|
||||||
|
});
|
||||||
|
const [firstAgent, secondAgent] = agents;
|
||||||
|
if (!firstAgent || !secondAgent) {
|
||||||
|
throw new Error("Expected two archived agents");
|
||||||
|
}
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByTestId("workspace-recovery-action").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||||
|
.toBe(true);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.10.4",
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/search": "6.7.1",
|
||||||
|
"@codemirror/state": "6.7.1",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -46,6 +51,7 @@
|
|||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@replit/codemirror-vim": "6.3.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
|
|||||||
@@ -257,6 +257,35 @@ describe("bottom anchor controller driver", () => {
|
|||||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
|
||||||
|
const harness = createDriverHarness({
|
||||||
|
transportBehavior: {
|
||||||
|
verificationDelayFrames: 2,
|
||||||
|
verificationRetryMode: "recheck",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.driver.prepareForStickyContentChange();
|
||||||
|
harness.driver.beginUserScroll();
|
||||||
|
harness.driver.handleContentSizeChange({
|
||||||
|
previousContentHeight: 1200,
|
||||||
|
contentHeight: 1400,
|
||||||
|
});
|
||||||
|
harness.context.nearBottom = false;
|
||||||
|
harness.driver.handleScrollNearBottomChange({
|
||||||
|
nextIsNearBottom: false,
|
||||||
|
scrollDelta: 1,
|
||||||
|
});
|
||||||
|
harness.scheduler.flushAll();
|
||||||
|
|
||||||
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||||
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||||
|
mode: "detached",
|
||||||
|
pendingRequest: null,
|
||||||
|
pendingVerification: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||||
const harness = createDriverHarness({
|
const harness = createDriverHarness({
|
||||||
isNearBottom: false,
|
isNearBottom: false,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver {
|
|||||||
resetForAgent: () => void;
|
resetForAgent: () => void;
|
||||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||||
|
beginUserScroll: () => void;
|
||||||
|
endUserScroll: () => void;
|
||||||
detachByUser: () => void;
|
detachByUser: () => void;
|
||||||
handleViewportMetricsChange: (params: {
|
handleViewportMetricsChange: (params: {
|
||||||
previousViewportWidth: number;
|
previousViewportWidth: number;
|
||||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
let lastRouteRequestKey: string | null = null;
|
let lastRouteRequestKey: string | null = null;
|
||||||
let stickyMeasurementRevision = 0;
|
let stickyMeasurementRevision = 0;
|
||||||
let lastVerifiedStickyMeasurementRevision = 0;
|
let lastVerifiedStickyMeasurementRevision = 0;
|
||||||
|
let isUserScrollActive = false;
|
||||||
|
|
||||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||||
if (blockedReason === nextBlockedReason) {
|
if (blockedReason === nextBlockedReason) {
|
||||||
@@ -481,6 +484,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
cancelPendingAttempt();
|
cancelPendingAttempt();
|
||||||
stickyMeasurementRevision = 0;
|
stickyMeasurementRevision = 0;
|
||||||
lastVerifiedStickyMeasurementRevision = 0;
|
lastVerifiedStickyMeasurementRevision = 0;
|
||||||
|
isUserScrollActive = false;
|
||||||
mode = "sticky-bottom";
|
mode = "sticky-bottom";
|
||||||
input.onModeChange("sticky-bottom");
|
input.onModeChange("sticky-bottom");
|
||||||
},
|
},
|
||||||
@@ -497,6 +501,21 @@ function createBottomAnchorControllerDriver(
|
|||||||
requestLocalAnchor(request) {
|
requestLocalAnchor(request) {
|
||||||
createRequest(request);
|
createRequest(request);
|
||||||
},
|
},
|
||||||
|
beginUserScroll() {
|
||||||
|
isUserScrollActive = true;
|
||||||
|
cancelPendingRequest("user_scroll_started");
|
||||||
|
},
|
||||||
|
endUserScroll() {
|
||||||
|
isUserScrollActive = false;
|
||||||
|
if (mode !== "sticky-bottom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.isNearBottom()) {
|
||||||
|
markStickyMeasurementVerified();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.detachByUser();
|
||||||
|
},
|
||||||
detachByUser() {
|
detachByUser() {
|
||||||
if (mode === "detached") {
|
if (mode === "detached") {
|
||||||
return;
|
return;
|
||||||
@@ -511,6 +530,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
) {
|
) {
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
}
|
}
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||||
mode,
|
mode,
|
||||||
previousViewportWidth: params.previousViewportWidth,
|
previousViewportWidth: params.previousViewportWidth,
|
||||||
@@ -529,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
if (params.previousContentHeight !== params.contentHeight) {
|
if (params.previousContentHeight !== params.contentHeight) {
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
}
|
}
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||||
mode,
|
mode,
|
||||||
previousContentHeight: params.previousContentHeight,
|
previousContentHeight: params.previousContentHeight,
|
||||||
@@ -558,6 +583,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!pendingRequest) {
|
if (!pendingRequest) {
|
||||||
pendingVerification = { requestId: null, retries: 0 };
|
pendingVerification = { requestId: null, retries: 0 };
|
||||||
if (attemptHandle) {
|
if (attemptHandle) {
|
||||||
@@ -571,6 +599,12 @@ function createBottomAnchorControllerDriver(
|
|||||||
},
|
},
|
||||||
handleScrollNearBottomChange(params) {
|
handleScrollNearBottomChange(params) {
|
||||||
const { nextIsNearBottom, scrollDelta } = params;
|
const { nextIsNearBottom, scrollDelta } = params;
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
if (mode === "sticky-bottom" && !nextIsNearBottom) {
|
||||||
|
this.detachByUser();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
nextIsNearBottom &&
|
nextIsNearBottom &&
|
||||||
mode === "sticky-bottom" &&
|
mode === "sticky-bottom" &&
|
||||||
@@ -737,6 +771,12 @@ export function useBottomAnchorController(input: {
|
|||||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||||
driverRef.current?.requestLocalAnchor(request);
|
driverRef.current?.requestLocalAnchor(request);
|
||||||
},
|
},
|
||||||
|
beginUserScroll() {
|
||||||
|
driverRef.current?.beginUserScroll();
|
||||||
|
},
|
||||||
|
endUserScroll() {
|
||||||
|
driverRef.current?.endUserScroll();
|
||||||
|
},
|
||||||
detachByUser() {
|
detachByUser() {
|
||||||
driverRef.current?.detachByUser();
|
driverRef.current?.detachByUser();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Platform,
|
||||||
View,
|
View,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
@@ -88,6 +89,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
contentMeasuredForKey: null as string | null,
|
contentMeasuredForKey: null as string | null,
|
||||||
});
|
});
|
||||||
const scrollOffsetYRef = useRef(0);
|
const scrollOffsetYRef = useRef(0);
|
||||||
|
const isUserScrollActiveRef = useRef(false);
|
||||||
|
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||||
const programmaticScrollEventBudgetRef = useRef(0);
|
const programmaticScrollEventBudgetRef = useRef(0);
|
||||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||||
@@ -128,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingUserScrollEnd = useCallback(() => {
|
||||||
|
if (userScrollEndFrameIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(userScrollEndFrameIdRef.current);
|
||||||
|
userScrollEndFrameIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markNativeViewportSettling = useCallback(() => {
|
const markNativeViewportSettling = useCallback(() => {
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
setIsNativeViewportSettling(true);
|
setIsNativeViewportSettling(true);
|
||||||
@@ -189,6 +199,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
},
|
},
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
});
|
});
|
||||||
|
// Android's maintainVisibleContentPosition ignores the list inversion transform and
|
||||||
|
// fights the controller's offset-zero correction while the live header grows.
|
||||||
|
const maintainVisibleContentPosition =
|
||||||
|
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||||
|
? undefined
|
||||||
|
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamViewportMetricsRef.current = {
|
streamViewportMetricsRef.current = {
|
||||||
@@ -201,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
contentMeasuredForKey: null,
|
contentMeasuredForKey: null,
|
||||||
};
|
};
|
||||||
scrollOffsetYRef.current = 0;
|
scrollOffsetYRef.current = 0;
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
setIsNativeViewportSettling(false);
|
setIsNativeViewportSettling(false);
|
||||||
historyStartReadyRef.current = false;
|
historyStartReadyRef.current = false;
|
||||||
@@ -209,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
};
|
};
|
||||||
}, [agentId, clearNativeViewportSettling]);
|
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardEvents = [
|
const keyboardEvents = [
|
||||||
@@ -294,7 +313,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
onNearHistoryStart();
|
onNearHistoryStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
if (
|
||||||
|
!isUserScrollActiveRef.current &&
|
||||||
|
programmaticScrollEventBudgetRef.current > 0 &&
|
||||||
|
contentOffset.y <= 8
|
||||||
|
) {
|
||||||
programmaticScrollEventBudgetRef.current -= 1;
|
programmaticScrollEventBudgetRef.current -= 1;
|
||||||
} else {
|
} else {
|
||||||
programmaticScrollEventBudgetRef.current = 0;
|
programmaticScrollEventBudgetRef.current = 0;
|
||||||
@@ -305,6 +328,31 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleScrollBeginDrag = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
isUserScrollActiveRef.current = true;
|
||||||
|
bottomAnchorController.beginUserScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScrollEndDrag = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||||
|
userScrollEndFrameIdRef.current = null;
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
bottomAnchorController.endUserScroll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMomentumScrollBegin = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMomentumScrollEnd = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
bottomAnchorController.endUserScroll();
|
||||||
|
});
|
||||||
|
|
||||||
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
||||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||||
@@ -412,9 +460,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
style={listStyle}
|
style={listStyle}
|
||||||
onLayout={handleListLayout}
|
onLayout={handleListLayout}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
|
onScrollBeginDrag={handleScrollBeginDrag}
|
||||||
|
onScrollEndDrag={handleScrollEndDrag}
|
||||||
|
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||||
|
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||||
initialNumToRender={40}
|
initialNumToRender={40}
|
||||||
maxToRenderPerBatch={40}
|
maxToRenderPerBatch={40}
|
||||||
updateCellsBatchingPeriod={0}
|
updateCellsBatchingPeriod={0}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import {
|
|||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
|
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ import {
|
|||||||
resolveStartupBlocker,
|
resolveStartupBlocker,
|
||||||
resolveStartupNavigationReady,
|
resolveStartupNavigationReady,
|
||||||
shouldRunStartupGiveUpTimer,
|
shouldRunStartupGiveUpTimer,
|
||||||
startDaemonIfGateAllows,
|
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
type StartupBlocker,
|
type StartupBlocker,
|
||||||
} from "@/navigation/host-runtime-bootstrap";
|
} from "@/navigation/host-runtime-bootstrap";
|
||||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||||
import { ThemedStack } from "@/navigation/themed-stack";
|
import { ThemedStack } from "@/navigation/themed-stack";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
@@ -147,9 +147,10 @@ function PushNotificationRouter() {
|
|||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
const target = resolveNotificationTarget(data);
|
const target = resolveNotificationTarget(data);
|
||||||
const serverId = target.serverId;
|
const serverId = target.serverId;
|
||||||
|
const workspaceId = target.workspaceId;
|
||||||
const agentId = target.agentId;
|
const agentId = target.agentId;
|
||||||
if (serverId && agentId) {
|
if (serverId && workspaceId && agentId) {
|
||||||
navigateToAgent({ serverId, agentId, pin: true });
|
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +338,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
store,
|
store,
|
||||||
daemonStartService,
|
daemonStartService,
|
||||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||||
onGateError: (message) => daemonStartService.recordError(message),
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -376,11 +376,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
startDaemonIfGateAllows({
|
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
|
||||||
onGateError: (message) => daemonStartService.recordError(message),
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const splashError =
|
const splashError =
|
||||||
@@ -914,6 +910,7 @@ function AppShell() {
|
|||||||
<MobilePanelsProvider>
|
<MobilePanelsProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<OpenProjectListener />
|
<OpenProjectListener />
|
||||||
|
<AgentNavigationListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<WorkspaceRouteNavigationBridge />
|
<WorkspaceRouteNavigationBridge />
|
||||||
<RootStack />
|
<RootStack />
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import { useFetchQuery } from "@/data/query";
|
||||||
|
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||||
|
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}>();
|
}>();
|
||||||
const redirectedRef = useRef(false);
|
const handledNavigationRef = useRef<string | null>(null);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||||
const client = useHostRuntimeClient(serverId);
|
const hosts = useHosts();
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const client = runtimeSnapshot?.client ?? null;
|
||||||
|
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||||
|
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||||
const agentWorkspaceId = useSessionStore((state) => {
|
const agentWorkspaceId = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||||
});
|
});
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const shouldLookupAgent = Boolean(
|
||||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||||
);
|
);
|
||||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
const lookupQuery = useFetchQuery({
|
||||||
|
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Target host client is unavailable");
|
||||||
|
}
|
||||||
|
const result = await client.fetchAgent({ agentId });
|
||||||
|
return result?.agent?.workspaceId ?? null;
|
||||||
|
},
|
||||||
|
enabled: shouldLookupAgent,
|
||||||
|
retry: false,
|
||||||
|
dataShape: "value",
|
||||||
|
staleTimeMs: 0,
|
||||||
|
});
|
||||||
|
const lookup = useMemo<AgentRouteLookup>(() => {
|
||||||
|
if (!shouldLookupAgent) {
|
||||||
|
return { kind: "idle" };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isFetching) {
|
||||||
|
return { kind: "fetching" };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isError) {
|
||||||
|
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isSuccess) {
|
||||||
|
return { kind: "found", workspaceId: lookupQuery.data };
|
||||||
|
}
|
||||||
|
return { kind: "fetching" };
|
||||||
|
}, [
|
||||||
|
lookupQuery.data,
|
||||||
|
lookupQuery.error,
|
||||||
|
lookupQuery.isError,
|
||||||
|
lookupQuery.isFetching,
|
||||||
|
lookupQuery.isSuccess,
|
||||||
|
shouldLookupAgent,
|
||||||
|
]);
|
||||||
|
const resolution = resolveAgentRoute({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
cachedWorkspaceId: agentWorkspaceId,
|
||||||
|
connectionStatus,
|
||||||
|
lookup,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
let navigationKey: string | null = null;
|
||||||
return;
|
if (resolution.kind === "invalid") {
|
||||||
|
navigationKey = "invalid";
|
||||||
|
} else if (resolution.kind === "resolved") {
|
||||||
|
navigationKey = `workspace:${resolution.workspaceId}`;
|
||||||
|
} else if (resolution.kind === "notFound") {
|
||||||
|
navigationKey = "not-found";
|
||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace("/" as Href);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handledNavigationRef.current = navigationKey;
|
||||||
|
|
||||||
if (resolvedWorkspaceId) {
|
if (resolution.kind === "resolved") {
|
||||||
redirectedRef.current = true;
|
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||||
navigateToAgent({
|
return;
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||||
|
}, [agentId, resolution, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRetry = useCallback(() => {
|
||||||
if (redirectedRef.current) {
|
if (resolution.kind === "lookupError") {
|
||||||
|
void lookupQuery.refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (serverId) {
|
||||||
|
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||||
|
}
|
||||||
|
}, [lookupQuery, resolution.kind, serverId]);
|
||||||
|
const handleManageHost = useCallback(() => {
|
||||||
|
if (serverId) {
|
||||||
|
router.push(buildSettingsHostRoute(serverId));
|
||||||
|
}
|
||||||
|
}, [router, serverId]);
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||||
return;
|
}, [router, serverId]);
|
||||||
}
|
|
||||||
if (!client || !isConnected) {
|
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
}
|
|
||||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (
|
||||||
if (redirectedRef.current) {
|
resolution.kind === "waitingForHost" ||
|
||||||
return;
|
resolution.kind === "fetchingAgent" ||
|
||||||
}
|
resolution.kind === "lookupError"
|
||||||
if (!serverId || !agentId || !client || !isConnected) {
|
) {
|
||||||
return;
|
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
|
||||||
}
|
// reconnects, then resolve the workspace from the authoritative agent record.
|
||||||
|
return (
|
||||||
let cancelled = false;
|
<AgentRouteResolutionView
|
||||||
void client
|
resolution={resolution}
|
||||||
.fetchAgent({ agentId })
|
hostName={hostName}
|
||||||
.then((result) => {
|
lastHostError={runtimeSnapshot?.lastError ?? null}
|
||||||
if (cancelled || redirectedRef.current) {
|
onRetry={handleRetry}
|
||||||
return;
|
onManageHost={handleManageHost}
|
||||||
}
|
onBack={handleBack}
|
||||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
/>
|
||||||
redirectedRef.current = true;
|
);
|
||||||
if (workspaceId) {
|
}
|
||||||
navigateToAgent({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled || redirectedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
import {
|
import {
|
||||||
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
|||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||||
|
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||||
|
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||||
);
|
);
|
||||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
|||||||
}
|
}
|
||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
|
||||||
if (openIntent) {
|
if (openIntent) {
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
|||||||
hasHydratedWorkspaceLayoutStore,
|
hasHydratedWorkspaceLayoutStore,
|
||||||
isOpenIntentWaitingForWorkspace,
|
isOpenIntentWaitingForWorkspace,
|
||||||
navigation,
|
navigation,
|
||||||
|
openIntent,
|
||||||
openValue,
|
openValue,
|
||||||
rootNavigationState?.key,
|
rootNavigationState?.key,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
function WorkspaceDeck({
|
||||||
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
|
}: {
|
||||||
|
recoveryRequested: boolean;
|
||||||
|
recoveryAgentId: string | null;
|
||||||
|
}) {
|
||||||
const activeSelection = useActiveWorkspaceSelection();
|
const activeSelection = useActiveWorkspaceSelection();
|
||||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
activeSelection ? [activeSelection] : [],
|
activeSelection ? [activeSelection] : [],
|
||||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
activeSelection={activeSelection}
|
activeSelection={activeSelection}
|
||||||
recoveryRequested={recoveryRequested}
|
recoveryRequested={recoveryRequested}
|
||||||
|
recoveryAgentId={recoveryAgentId}
|
||||||
onUnmountInactive={unmountWorkspaceSelection}
|
onUnmountInactive={unmountWorkspaceSelection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
|
|||||||
selection,
|
selection,
|
||||||
activeSelection,
|
activeSelection,
|
||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
onUnmountInactive,
|
onUnmountInactive,
|
||||||
}: {
|
}: {
|
||||||
selection: ActiveWorkspaceSelection;
|
selection: ActiveWorkspaceSelection;
|
||||||
activeSelection: ActiveWorkspaceSelection;
|
activeSelection: ActiveWorkspaceSelection;
|
||||||
recoveryRequested: boolean;
|
recoveryRequested: boolean;
|
||||||
|
recoveryAgentId: string | null;
|
||||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||||
}) {
|
}) {
|
||||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
|||||||
workspaceId={selection.workspaceId}
|
workspaceId={selection.workspaceId}
|
||||||
isRouteFocused={isActive}
|
isRouteFocused={isActive}
|
||||||
recoveryRequested={isActive && recoveryRequested}
|
recoveryRequested={isActive && recoveryRequested}
|
||||||
|
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||||
/>
|
/>
|
||||||
</RetainedPanel>
|
</RetainedPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,9 +92,20 @@ export interface ChatHistoryContextAttachment {
|
|||||||
|
|
||||||
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
||||||
|
|
||||||
|
export type WorkspaceFileSelection =
|
||||||
|
| { kind: "whole_file" }
|
||||||
|
| { kind: "line_range"; startLine: number; endLine: number };
|
||||||
|
|
||||||
|
export interface WorkspaceFileComposerAttachment {
|
||||||
|
kind: "workspace_file";
|
||||||
|
path: string;
|
||||||
|
selection: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
|
|
||||||
export type UserComposerAttachment =
|
export type UserComposerAttachment =
|
||||||
| { kind: "image"; metadata: AttachmentMetadata }
|
| { kind: "image"; metadata: AttachmentMetadata }
|
||||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||||
|
| WorkspaceFileComposerAttachment
|
||||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { RefCallback } from "react";
|
||||||
|
import type { View } from "react-native";
|
||||||
|
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||||
|
|
||||||
|
export function useWorkspaceFileDragSource(
|
||||||
|
_input: WorkspaceFileDragSourceInput,
|
||||||
|
): RefCallback<View> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback, useEffect, useState, type RefCallback } from "react";
|
||||||
|
import type { View } from "react-native";
|
||||||
|
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||||
|
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
|
||||||
|
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||||
|
|
||||||
|
export function useWorkspaceFileDragSource({
|
||||||
|
enabled,
|
||||||
|
disabled = false,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
path,
|
||||||
|
selection,
|
||||||
|
}: WorkspaceFileDragSourceInput): RefCallback<View> {
|
||||||
|
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||||
|
const dragSourceRef = useCallback((node: View | null) => {
|
||||||
|
setElement(node as unknown as HTMLElement | null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element || !enabled || disabled || !serverId || !workspaceId) {
|
||||||
|
if (element) {
|
||||||
|
element.draggable = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceServerId = serverId;
|
||||||
|
const sourceWorkspaceId = workspaceId;
|
||||||
|
element.draggable = true;
|
||||||
|
function handleDragStart(event: DragEvent) {
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
WORKSPACE_FILE_DRAG_MIME,
|
||||||
|
serializeWorkspaceFileDragPayload({
|
||||||
|
version: 1,
|
||||||
|
serverId: sourceServerId,
|
||||||
|
workspaceId: sourceWorkspaceId,
|
||||||
|
attachment: createWorkspaceFileAttachment({ path, selection }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
element.addEventListener("dragstart", handleDragStart);
|
||||||
|
return () => {
|
||||||
|
element.draggable = false;
|
||||||
|
element.removeEventListener("dragstart", handleDragStart);
|
||||||
|
};
|
||||||
|
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
|
||||||
|
|
||||||
|
return dragSourceRef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { WorkspaceFileSelection } from "./types";
|
||||||
|
|
||||||
|
export interface WorkspaceFileDragSourceInput {
|
||||||
|
enabled: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
serverId?: string;
|
||||||
|
workspaceId: string | null | undefined;
|
||||||
|
path: string;
|
||||||
|
selection?: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||||
|
import {
|
||||||
|
parseWorkspaceFileDragPayload,
|
||||||
|
resolveWorkspaceFileDrop,
|
||||||
|
serializeWorkspaceFileDragPayload,
|
||||||
|
type WorkspaceFileDragPayload,
|
||||||
|
} from "./workspace-file-drag";
|
||||||
|
|
||||||
|
function payload(): WorkspaceFileDragPayload {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
attachment: createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace file drag payload", () => {
|
||||||
|
it("round-trips workspace identity and future line selections", () => {
|
||||||
|
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
|
||||||
|
payload(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed and invalid payloads", () => {
|
||||||
|
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseWorkspaceFileDragPayload(
|
||||||
|
JSON.stringify({
|
||||||
|
...payload(),
|
||||||
|
attachment: {
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 24, endLine: 12 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts drops only within the originating server and workspace", () => {
|
||||||
|
const dragged = payload();
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceFileDrop({
|
||||||
|
payload: dragged,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
}),
|
||||||
|
).toEqual(dragged.attachment);
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceFileDrop({
|
||||||
|
payload: dragged,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-2",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { WorkspaceFileComposerAttachment } from "./types";
|
||||||
|
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
|
||||||
|
|
||||||
|
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
|
||||||
|
|
||||||
|
export interface WorkspaceFileDragPayload {
|
||||||
|
version: 1;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
record.version !== 1 ||
|
||||||
|
typeof record.serverId !== "string" ||
|
||||||
|
record.serverId.length === 0 ||
|
||||||
|
typeof record.workspaceId !== "string" ||
|
||||||
|
record.workspaceId.length === 0 ||
|
||||||
|
!isWorkspaceFileComposerAttachment(record.attachment)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
serverId: record.serverId,
|
||||||
|
workspaceId: record.workspaceId,
|
||||||
|
attachment: record.attachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceFileDrop(input: {
|
||||||
|
payload: WorkspaceFileDragPayload;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): WorkspaceFileComposerAttachment | null {
|
||||||
|
return input.payload.serverId === input.serverId &&
|
||||||
|
input.payload.workspaceId === input.workspaceId
|
||||||
|
? input.payload.attachment
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import {
|
||||||
|
appendWorkspaceFileAttachment,
|
||||||
|
createWorkspaceFileAttachment,
|
||||||
|
getWorkspaceFileAttachmentKey,
|
||||||
|
getWorkspaceFileAttachmentSubtitle,
|
||||||
|
workspaceFileAttachmentToAgentAttachment,
|
||||||
|
} from "./workspace-file";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||||
|
|
||||||
|
describe("workspace file attachments", () => {
|
||||||
|
it("models whole files and line ranges as distinct selections", () => {
|
||||||
|
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||||
|
const lineRange = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wholeFile).toEqual({
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "whole_file" },
|
||||||
|
});
|
||||||
|
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
|
||||||
|
getWorkspaceFileAttachmentKey(lineRange),
|
||||||
|
);
|
||||||
|
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
|
||||||
|
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates only identical paths and selections", () => {
|
||||||
|
const image = {
|
||||||
|
kind: "image" as const,
|
||||||
|
metadata: {
|
||||||
|
id: "image-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb" as const,
|
||||||
|
storageKey: "image-1",
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||||
|
const range = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 1, endLine: 5 },
|
||||||
|
});
|
||||||
|
const current: UserComposerAttachment[] = [image, wholeFile];
|
||||||
|
|
||||||
|
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
|
||||||
|
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a path reference without uploading or inserting prompt text", () => {
|
||||||
|
const attachment = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: "app.ts",
|
||||||
|
text: "Workspace file: src/app.ts\nLines: 12-24",
|
||||||
|
});
|
||||||
|
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
|
||||||
|
images: [],
|
||||||
|
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
|
import type {
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceFileComposerAttachment,
|
||||||
|
WorkspaceFileSelection,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface CreateWorkspaceFileAttachmentInput {
|
||||||
|
path: string;
|
||||||
|
selection?: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.trim().replace(/^\.\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceFileComposerAttachment(
|
||||||
|
value: unknown,
|
||||||
|
): value is WorkspaceFileComposerAttachment {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
record.kind !== "workspace_file" ||
|
||||||
|
typeof record.path !== "string" ||
|
||||||
|
record.path.trim().length === 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const selection = record.selection;
|
||||||
|
if (!selection || typeof selection !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { kind, startLine, endLine } = selection as Record<string, unknown>;
|
||||||
|
if (kind === "whole_file") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
kind === "line_range" &&
|
||||||
|
typeof startLine === "number" &&
|
||||||
|
Number.isInteger(startLine) &&
|
||||||
|
typeof endLine === "number" &&
|
||||||
|
Number.isInteger(endLine) &&
|
||||||
|
startLine > 0 &&
|
||||||
|
endLine >= startLine
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceFileAttachment({
|
||||||
|
path,
|
||||||
|
selection = { kind: "whole_file" },
|
||||||
|
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
|
||||||
|
return {
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: normalizePath(path),
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
|
||||||
|
const selection = attachment.selection;
|
||||||
|
const selectionKey =
|
||||||
|
selection.kind === "whole_file"
|
||||||
|
? selection.kind
|
||||||
|
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
|
||||||
|
return `${normalizePath(attachment.path)}:${selectionKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendWorkspaceFileAttachment(
|
||||||
|
current: UserComposerAttachment[],
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
|
||||||
|
const alreadyAttached = current.some(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.kind === "workspace_file" &&
|
||||||
|
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
|
||||||
|
);
|
||||||
|
return alreadyAttached ? current : [...current, attachment];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceFileAttachmentToAgentAttachment(
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): Extract<AgentAttachment, { type: "text" }> {
|
||||||
|
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||||
|
const lines =
|
||||||
|
attachment.selection.kind === "line_range"
|
||||||
|
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: fileName,
|
||||||
|
text: `Workspace file: ${attachment.path}${lines}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceFileAttachmentSubtitle(
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): string {
|
||||||
|
if (attachment.selection.kind === "whole_file") {
|
||||||
|
return attachment.path;
|
||||||
|
}
|
||||||
|
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
|
||||||
|
}
|
||||||
@@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
|||||||
import { mountBrowserAutomationHandler } from "./handler";
|
import { mountBrowserAutomationHandler } from "./handler";
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
import { useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
buildWorkspaceTabPersistenceKey,
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import {
|
|||||||
resizeResidentBrowserWebview,
|
resizeResidentBrowserWebview,
|
||||||
} from "@/components/browser-webview-resident";
|
} from "@/components/browser-webview-resident";
|
||||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
buildWorkspaceTabPersistenceKey,
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
collectAllTabs,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export function AgentList({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
workspaceId: agent.workspaceId,
|
workspaceId: agent.workspaceId,
|
||||||
|
pin: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
interface ArchivedAgentCalloutProps {
|
interface ArchivedAgentCalloutProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||||
|
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||||
|
|
||||||
@@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
const handleUnarchive = useCallback(async () => {
|
const handleUnarchive = useCallback(async () => {
|
||||||
if (!client || !isConnected || isUnarchiving) return;
|
if (!client || !isConnected || isUnarchiving) return;
|
||||||
setIsUnarchiving(true);
|
setIsUnarchiving(true);
|
||||||
|
setUnarchiveError(null);
|
||||||
try {
|
try {
|
||||||
await client.refreshAgent(agentId);
|
await client.refreshAgent(agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
setUnarchiveError(toErrorMessage(error));
|
||||||
setIsUnarchiving(false);
|
setIsUnarchiving(false);
|
||||||
}
|
}
|
||||||
}, [client, isConnected, isUnarchiving, agentId]);
|
}, [client, isConnected, isUnarchiving, agentId]);
|
||||||
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
<Animated.View style={containerStyle}>
|
<Animated.View style={containerStyle}>
|
||||||
<View style={styles.inputAreaContainer}>
|
<View style={styles.inputAreaContainer}>
|
||||||
<View style={styles.inputAreaContent}>
|
<View style={styles.inputAreaContent}>
|
||||||
<View style={styles.callout}>
|
<View style={styles.calloutStack}>
|
||||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
<View style={styles.callout}>
|
||||||
<Button
|
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||||
size="sm"
|
<Button
|
||||||
variant="secondary"
|
size="sm"
|
||||||
onPress={handleUnarchive}
|
variant="secondary"
|
||||||
disabled={!isConnected || isUnarchiving}
|
onPress={handleUnarchive}
|
||||||
>
|
disabled={!isConnected || isUnarchiving}
|
||||||
{t("agentPanel.archived.unarchive")}
|
>
|
||||||
</Button>
|
{t("agentPanel.archived.unarchive")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{unarchiveError ? (
|
||||||
|
<Text style={styles.errorText} testID="agent-unarchive-error">
|
||||||
|
{unarchiveError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
md: theme.spacing[6],
|
md: theme.spacing[6],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
calloutStack: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
calloutText: {
|
calloutText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.statusDanger,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
})) as unknown as Record<string, object>;
|
})) as unknown as Record<string, object>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
interface DiffStatProps {
|
interface DiffStatProps {
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||||
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
|
|||||||
return compactFormatter.format(value).toLowerCase();
|
return compactFormatter.format(value).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row} testID={testID}>
|
||||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
|||||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||||
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
|
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||||
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
|
|
||||||
@@ -386,15 +391,16 @@ function ExplorerSidebarContent({
|
|||||||
{/* Content based on active tab */}
|
{/* Content based on active tab */}
|
||||||
<View style={styles.contentArea} testID="explorer-content-area">
|
<View style={styles.contentArea} testID="explorer-content-area">
|
||||||
{resolvedTab === "changes" && (
|
{resolvedTab === "changes" && (
|
||||||
<GitDiffPane
|
<ChangedFilesPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
cwd={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
enabled={isOpen}
|
isOpen={isOpen}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{resolvedTab === "files" && (
|
{resolvedTab === "files" && (
|
||||||
<FileExplorerPane
|
<FilesPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
@@ -415,6 +421,83 @@ function ExplorerSidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||||
|
* to chat" action that attaches the file to the focused chat's composer.
|
||||||
|
* Available only when a workspace with a focused chat is available.
|
||||||
|
*/
|
||||||
|
function useAddFileToChat({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||||
|
const workspaceKey = workspaceId
|
||||||
|
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||||
|
: null;
|
||||||
|
const layout = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||||
|
);
|
||||||
|
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
|
const focusedChat = useMemo(
|
||||||
|
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||||
|
[serverId, layout],
|
||||||
|
);
|
||||||
|
const addFile = useCallback(
|
||||||
|
(filePath: string) => {
|
||||||
|
if (!focusedChat || !workspaceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void useDraftStore.getState().attachWorkspaceFile({
|
||||||
|
draftKey: focusedChat.draftKey,
|
||||||
|
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||||
|
});
|
||||||
|
focusTab(workspaceKey, focusedChat.tabId);
|
||||||
|
},
|
||||||
|
[focusTab, focusedChat, workspaceKey],
|
||||||
|
);
|
||||||
|
return { addFile, canAddToChat: focusedChat !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangedFilesPane({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
isOpen,
|
||||||
|
onOpenFile,
|
||||||
|
}: Pick<
|
||||||
|
SidebarContentProps,
|
||||||
|
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||||
|
>) {
|
||||||
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
return (
|
||||||
|
<GitDiffPane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
cwd={workspaceRoot}
|
||||||
|
enabled={isOpen}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesPane({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
onOpenFile,
|
||||||
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||||
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
return (
|
||||||
|
<FileExplorerPane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PrTabContentProps {
|
interface PrTabContentProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { type PressableStateCallbackType } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
MessageSquarePlus,
|
||||||
|
MoreVertical,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
|
||||||
|
/** Width occupied by a file action trigger, including its visual padding. */
|
||||||
|
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
|
||||||
|
|
||||||
|
interface FileAction {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
onSelect: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileActionsMenuProps {
|
||||||
|
fileKind: "file" | "directory";
|
||||||
|
fileExists?: boolean;
|
||||||
|
onOpenFile?: () => void;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onAddToChat?: () => void;
|
||||||
|
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||||
|
header?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
hitSlop?: number;
|
||||||
|
accessibilityLabel: string;
|
||||||
|
testIDPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The menu lives inside pressable rows (diff header, explorer entry); stop the
|
||||||
|
// press so opening it doesn't also trigger the row.
|
||||||
|
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
|
||||||
|
event.stopPropagation?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerStyle({
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
open,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||||
|
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
|
||||||
|
* git diff pane so both surfaces share action availability, ordering, and chrome.
|
||||||
|
*/
|
||||||
|
export function FileActionsMenu({
|
||||||
|
fileKind,
|
||||||
|
fileExists = true,
|
||||||
|
onOpenFile,
|
||||||
|
onCopyPath,
|
||||||
|
onDownload,
|
||||||
|
onAddToChat,
|
||||||
|
header,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
hitSlop = 12,
|
||||||
|
accessibilityLabel,
|
||||||
|
testIDPrefix,
|
||||||
|
}: FileActionsMenuProps): ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actions = useMemo<FileAction[]>(() => {
|
||||||
|
const availableFile = fileKind === "file" && fileExists;
|
||||||
|
const next: FileAction[] = [];
|
||||||
|
if (availableFile && onOpenFile) {
|
||||||
|
next.push({
|
||||||
|
key: "open-file",
|
||||||
|
label: t("workspace.fileActions.openFile"),
|
||||||
|
icon: FileText,
|
||||||
|
onSelect: onOpenFile,
|
||||||
|
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onCopyPath) {
|
||||||
|
next.push({
|
||||||
|
key: "copy-path",
|
||||||
|
label: t("workspace.fileActions.copyPath"),
|
||||||
|
icon: Copy,
|
||||||
|
onSelect: onCopyPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (availableFile && onDownload) {
|
||||||
|
next.push({
|
||||||
|
key: "download",
|
||||||
|
label: t("workspace.fileActions.download"),
|
||||||
|
icon: Download,
|
||||||
|
onSelect: onDownload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (availableFile && onAddToChat) {
|
||||||
|
next.push({
|
||||||
|
key: "add-to-chat",
|
||||||
|
label: t("workspace.fileActions.addToChat"),
|
||||||
|
icon: MessageSquarePlus,
|
||||||
|
onSelect: onAddToChat,
|
||||||
|
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={hitSlop}
|
||||||
|
onPressIn={stopTriggerPropagation}
|
||||||
|
style={triggerStyle}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
|
||||||
|
>
|
||||||
|
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
{header ? (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{actions.map((action) => (
|
||||||
|
<FileActionMenuItem key={action.key} action={action} />
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||||
|
const leading = useMemo(
|
||||||
|
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
||||||
|
[ThemedIcon],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
trigger: {
|
||||||
|
// The hover box comes from padding, but an equal negative vertical margin
|
||||||
|
// cancels its height contribution so the trigger overlaps the row's natural
|
||||||
|
// line height instead of growing it. The comfortable tap target is `hitSlop`,
|
||||||
|
// never padding.
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
width: FILE_ACTIONS_MENU_WIDTH,
|
||||||
|
marginVertical: -theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
triggerActive: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ImageAttachment } from "@/composer/types";
|
import type { ImageAttachment } from "@/composer/types";
|
||||||
|
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
export interface DroppedFileItem {
|
export interface DroppedFileItem {
|
||||||
kind: "web-file";
|
kind: "web-file";
|
||||||
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
|||||||
export interface FileDropSink {
|
export interface FileDropSink {
|
||||||
onFiles: (images: ImageAttachment[]) => void;
|
onFiles: (images: ImageAttachment[]) => void;
|
||||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||||
|
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
} from "@/attachments/file-types";
|
} from "@/attachments/file-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||||
|
import {
|
||||||
|
parseWorkspaceFileDragPayload,
|
||||||
|
WORKSPACE_FILE_DRAG_MIME,
|
||||||
|
} from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
type DesktopDragDropPayload =
|
type DesktopDragDropPayload =
|
||||||
| { type: "enter"; paths: string[] }
|
| { type: "enter"; paths: string[] }
|
||||||
@@ -185,7 +189,11 @@ export function useDropListeners({
|
|||||||
if (disabledRef.current) return;
|
if (disabledRef.current) return;
|
||||||
|
|
||||||
dragCounter.current++;
|
dragCounter.current++;
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
if (suppressed.value || !hasSink.value) return;
|
||||||
|
const types = new Set(e.dataTransfer?.types ?? []);
|
||||||
|
const acceptsWorkspaceFile =
|
||||||
|
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||||
|
if (types.has("Files") || acceptsWorkspaceFile) {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,11 @@ export function useDropListeners({
|
|||||||
if (!e.dataTransfer) return;
|
if (!e.dataTransfer) return;
|
||||||
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||||
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||||
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
const types = new Set(e.dataTransfer.types);
|
||||||
|
const acceptsWorkspaceFile =
|
||||||
|
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||||
|
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
|
||||||
|
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
|
||||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +237,14 @@ export function useDropListeners({
|
|||||||
const sink = getSink();
|
const sink = getSink();
|
||||||
if (!sink) return;
|
if (!sink) return;
|
||||||
|
|
||||||
|
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
|
||||||
|
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
|
||||||
|
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
|
||||||
|
if (payload) {
|
||||||
|
sink.onWorkspaceFile(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||||
kind: "web-file",
|
kind: "web-file",
|
||||||
|
|||||||
@@ -15,36 +15,26 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { SvgXml } from "react-native-svg";
|
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||||
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
TreeChevron,
|
||||||
Copy,
|
TreeIndentGuides,
|
||||||
Download,
|
treeRowPaddingLeft,
|
||||||
Eye,
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
EyeOff,
|
} from "@/components/tree-primitives";
|
||||||
MoreVertical,
|
|
||||||
RotateCw,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
|
||||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDownloadStore } from "@/stores/download-store";
|
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||||
import {
|
import { useFileDownload } from "@/hooks/use-file-download";
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||||
|
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||||
{ value: "name" },
|
{ value: "name" },
|
||||||
@@ -63,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TreeRowItemProps {
|
interface TreeRowItemProps {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
entry: ExplorerEntry;
|
entry: ExplorerEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -71,21 +63,8 @@ interface TreeRowItemProps {
|
|||||||
onEntryPress: (entry: ExplorerEntry) => void;
|
onEntryPress: (entry: ExplorerEntry) => void;
|
||||||
onCopyPath: (path: string) => void;
|
onCopyPath: (path: string) => void;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
}
|
onAddToChat?: (path: string) => void;
|
||||||
|
testID?: string;
|
||||||
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
|
|
||||||
event.stopPropagation?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function menuButtonStyle({
|
|
||||||
hovered,
|
|
||||||
pressed,
|
|
||||||
open,
|
|
||||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
|
||||||
return [
|
|
||||||
styles.menuButton,
|
|
||||||
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTriggerStyle({
|
function sortTriggerStyle({
|
||||||
@@ -104,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeRowItem({
|
function TreeRowItem({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
entry,
|
entry,
|
||||||
depth,
|
depth,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -112,10 +93,17 @@ function TreeRowItem({
|
|||||||
onEntryPress,
|
onEntryPress,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
|
onAddToChat,
|
||||||
|
testID,
|
||||||
}: TreeRowItemProps) {
|
}: TreeRowItemProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDirectory = entry.kind === "directory";
|
const isDirectory = entry.kind === "directory";
|
||||||
|
const dragSourceRef = useWorkspaceFileDragSource({
|
||||||
|
enabled: !isDirectory,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
path: entry.path,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
onEntryPress(entry);
|
onEntryPress(entry);
|
||||||
@@ -124,10 +112,10 @@ function TreeRowItem({
|
|||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.entryRow,
|
styles.entryRow,
|
||||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||||
],
|
],
|
||||||
[depth, isSelected, theme.spacing],
|
[depth, isSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
@@ -138,23 +126,42 @@ function TreeRowItem({
|
|||||||
onDownloadEntry(entry);
|
onDownloadEntry(entry);
|
||||||
}, [onDownloadEntry, entry]);
|
}, [onDownloadEntry, entry]);
|
||||||
|
|
||||||
const copyLeading = useMemo(
|
const handleAddToChat = useCallback(() => {
|
||||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
onAddToChat?.(entry.path);
|
||||||
[theme.colors.foregroundMuted],
|
}, [onAddToChat, entry.path]);
|
||||||
);
|
|
||||||
const downloadLeading = useMemo(
|
const metaHeader = useMemo(
|
||||||
() => <Download size={14} color={theme.colors.foregroundMuted} />,
|
() => (
|
||||||
[theme.colors.foregroundMuted],
|
<View style={styles.contextMetaBlock}>
|
||||||
|
<View style={styles.contextMetaRow}>
|
||||||
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||||
|
{t("workspace.fileExplorer.context.size")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{formatFileSize({ size: entry.size })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.contextMetaRow}>
|
||||||
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||||
|
{t("workspace.fileExplorer.context.modified")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[entry.modifiedAt, entry.size, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||||
<TreeIndentGuides depth={depth} />
|
<TreeIndentGuides depth={depth} />
|
||||||
<View style={styles.entryInfo}>
|
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||||
}
|
}
|
||||||
if (loading) return <ActivityIndicator size="small" />;
|
if (loading) return <ActivityIndicator size="small" />;
|
||||||
return <TreeChevron expanded={isExpanded} />;
|
return <TreeChevron expanded={isExpanded} />;
|
||||||
@@ -164,40 +171,15 @@ function TreeRowItem({
|
|||||||
{entry.name}
|
{entry.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu>
|
<FileActionsMenu
|
||||||
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
|
fileKind={entry.kind}
|
||||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
onCopyPath={handleCopy}
|
||||||
</DropdownMenuTrigger>
|
onDownload={handleDownload}
|
||||||
<DropdownMenuContent align="end" width={220}>
|
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||||
<View style={styles.contextMetaBlock}>
|
header={metaHeader}
|
||||||
<View style={styles.contextMetaRow}>
|
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
testIDPrefix={testID}
|
||||||
{t("workspace.fileExplorer.context.size")}
|
/>
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatFileSize({ size: entry.size })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contextMetaRow}>
|
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
||||||
{t("workspace.fileExplorer.context.modified")}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
|
||||||
{t("workspace.fileExplorer.context.copyPath")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{entry.kind === "file" ? (
|
|
||||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
|
||||||
{t("workspace.fileExplorer.context.download")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,6 +189,7 @@ interface FileExplorerPaneProps {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
|
onAddToChat?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeRow {
|
interface TreeRow {
|
||||||
@@ -219,14 +202,10 @@ export function FileExplorerPane({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onAddToChat,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const daemons = useHosts();
|
|
||||||
const daemonProfile = useMemo(
|
|
||||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
|
||||||
[daemons, serverId],
|
|
||||||
);
|
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -236,10 +215,6 @@ export function FileExplorerPane({
|
|||||||
}),
|
}),
|
||||||
[normalizedWorkspaceRoot, workspaceId],
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
);
|
);
|
||||||
const workspaceScopeId = useMemo(
|
|
||||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
|
||||||
[normalizedWorkspaceRoot, workspaceId],
|
|
||||||
);
|
|
||||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||||
const explorerState = useSessionStore((state) =>
|
const explorerState = useSessionStore((state) =>
|
||||||
workspaceStateKey && state.sessions[serverId]
|
workspaceStateKey && state.sessions[serverId]
|
||||||
@@ -247,12 +222,16 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||||
useFileExplorerActions({
|
serverId,
|
||||||
serverId,
|
workspaceId,
|
||||||
workspaceId,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
});
|
||||||
});
|
const downloadFile = useFileDownload({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||||
@@ -347,18 +326,14 @@ export function FileExplorerPane({
|
|||||||
[normalizedWorkspaceRoot],
|
[normalizedWorkspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
|
||||||
const handleDownloadEntry = useCallback(
|
const handleDownloadEntry = useCallback(
|
||||||
(entry: ExplorerEntry) =>
|
(entry: ExplorerEntry) => {
|
||||||
downloadExplorerEntry({
|
if (entry.kind !== "file") {
|
||||||
entry,
|
return;
|
||||||
workspaceScopeId,
|
}
|
||||||
serverId,
|
downloadFile({ fileName: entry.name, path: entry.path });
|
||||||
daemonProfile,
|
},
|
||||||
startDownload,
|
[downloadFile],
|
||||||
requestFileDownloadToken,
|
|
||||||
}),
|
|
||||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortCycle = useCallback(() => {
|
const handleSortCycle = useCallback(() => {
|
||||||
@@ -420,6 +395,8 @@ export function FileExplorerPane({
|
|||||||
const renderTreeRow = useCallback(
|
const renderTreeRow = useCallback(
|
||||||
(info: ListRenderItemInfo<TreeRow>) => (
|
(info: ListRenderItemInfo<TreeRow>) => (
|
||||||
<TreeRowDispatcher
|
<TreeRowDispatcher
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
info={info}
|
info={info}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
selectedEntryPath={selectedEntryPath}
|
selectedEntryPath={selectedEntryPath}
|
||||||
@@ -427,6 +404,7 @@ export function FileExplorerPane({
|
|||||||
onEntryPress={handleEntryPress}
|
onEntryPress={handleEntryPress}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
onDownloadEntry={handleDownloadEntry}
|
onDownloadEntry={handleDownloadEntry}
|
||||||
|
onAddToChat={onAddToChat}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
@@ -436,6 +414,9 @@ export function FileExplorerPane({
|
|||||||
handleDownloadEntry,
|
handleDownloadEntry,
|
||||||
isDirectoryLoading,
|
isDirectoryLoading,
|
||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
|
onAddToChat,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -577,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<View style={styles.paneHeader} testID="files-pane-header">
|
||||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
<Pressable
|
||||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
onPress={handleSortCycle}
|
||||||
|
style={sortTriggerStyleProp}
|
||||||
|
testID="files-sort-trigger"
|
||||||
|
>
|
||||||
|
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||||
|
{currentSortLabel}
|
||||||
|
</Text>
|
||||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
@@ -589,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||||
|
testID="files-hidden-toggle"
|
||||||
>
|
>
|
||||||
{showHiddenFiles ? (
|
{showHiddenFiles ? (
|
||||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
@@ -607,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
? t("workspace.fileExplorer.actions.refreshing")
|
? t("workspace.fileExplorer.actions.refreshing")
|
||||||
: t("workspace.fileExplorer.actions.refresh")
|
: t("workspace.fileExplorer.actions.refresh")
|
||||||
}
|
}
|
||||||
|
testID="files-refresh"
|
||||||
>
|
>
|
||||||
<View style={styles.refreshIcon}>
|
<View style={styles.refreshIcon}>
|
||||||
{isRefreshFetching ? (
|
{isRefreshFetching ? (
|
||||||
@@ -779,39 +768,6 @@ function resolveTreeRows({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
|
|
||||||
type StartDownloadParams = Parameters<StartDownloadFn>[0];
|
|
||||||
|
|
||||||
function downloadExplorerEntry({
|
|
||||||
entry,
|
|
||||||
workspaceScopeId,
|
|
||||||
serverId,
|
|
||||||
daemonProfile,
|
|
||||||
startDownload,
|
|
||||||
requestFileDownloadToken,
|
|
||||||
}: {
|
|
||||||
entry: ExplorerEntry;
|
|
||||||
workspaceScopeId: string | undefined;
|
|
||||||
serverId: string;
|
|
||||||
daemonProfile: StartDownloadParams["daemonProfile"];
|
|
||||||
startDownload: StartDownloadFn;
|
|
||||||
requestFileDownloadToken: (
|
|
||||||
targetPath: string,
|
|
||||||
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
|
|
||||||
}): void {
|
|
||||||
if (!workspaceScopeId || entry.kind !== "file") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startDownload({
|
|
||||||
serverId,
|
|
||||||
scopeId: workspaceScopeId,
|
|
||||||
fileName: entry.name,
|
|
||||||
path: entry.path,
|
|
||||||
daemonProfile,
|
|
||||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDirectory({
|
function toggleDirectory({
|
||||||
entry,
|
entry,
|
||||||
workspaceStateKey,
|
workspaceStateKey,
|
||||||
@@ -851,6 +807,8 @@ function toggleDirectory({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeRowDispatcher({
|
function TreeRowDispatcher({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
info,
|
info,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
@@ -858,7 +816,10 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress,
|
onEntryPress,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
|
onAddToChat,
|
||||||
}: {
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
info: ListRenderItemInfo<TreeRow>;
|
info: ListRenderItemInfo<TreeRow>;
|
||||||
expandedPaths: Set<string>;
|
expandedPaths: Set<string>;
|
||||||
selectedEntryPath: string | null;
|
selectedEntryPath: string | null;
|
||||||
@@ -866,6 +827,7 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress: (entry: ExplorerEntry) => void;
|
onEntryPress: (entry: ExplorerEntry) => void;
|
||||||
onCopyPath: (path: string) => void | Promise<void>;
|
onCopyPath: (path: string) => void | Promise<void>;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
|
onAddToChat?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const entry = info.item.entry;
|
const entry = info.item.entry;
|
||||||
const depth = info.item.depth;
|
const depth = info.item.depth;
|
||||||
@@ -876,6 +838,8 @@ function TreeRowDispatcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeRowItem
|
<TreeRowItem
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
@@ -884,6 +848,8 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress={onEntryPress}
|
onEntryPress={onEntryPress}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onDownloadEntry={onDownloadEntry}
|
onDownloadEntry={onDownloadEntry}
|
||||||
|
onAddToChat={onAddToChat}
|
||||||
|
testID={`file-explorer-row-${info.index}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1062,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
entriesContent: {
|
entriesContent: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
@@ -1112,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 2,
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
@@ -1134,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
menuButton: {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
menuButtonActive: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
contextMetaBlock: {
|
contextMetaBlock: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -341,6 +341,64 @@ function FooterIconButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function footerAddProjectButtonStyle({
|
||||||
|
hovered,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function FooterAddProjectButton({
|
||||||
|
onPress,
|
||||||
|
label,
|
||||||
|
shortcutKeys,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
shortcutKeys: ReturnType<typeof useShortcutKeys>;
|
||||||
|
theme: SidebarTheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
style={footerAddProjectButtonStyle}
|
||||||
|
testID="sidebar-add-project"
|
||||||
|
nativeID="sidebar-add-project"
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
{({ hovered }) => {
|
||||||
|
const isHovered = Boolean(hovered);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FolderPlus
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.footerAddProjectLabel,
|
||||||
|
isHovered && styles.footerAddProjectLabelHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarHostPicker({
|
function SidebarHostPicker({
|
||||||
theme,
|
theme,
|
||||||
label,
|
label,
|
||||||
@@ -494,6 +552,12 @@ function SidebarFooter({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
|
<FooterAddProjectButton
|
||||||
|
onPress={handleOpenProject}
|
||||||
|
label={labels.addProject}
|
||||||
|
shortcutKeys={newAgentKeys}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
<View style={styles.footerIconRow}>
|
<View style={styles.footerIconRow}>
|
||||||
<SidebarHostPicker
|
<SidebarHostPicker
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -501,14 +565,6 @@ function SidebarFooter({
|
|||||||
onAddHost={handleAddHost}
|
onAddHost={handleAddHost}
|
||||||
onOpenHostSettings={handleOpenHostSettings}
|
onOpenHostSettings={handleOpenHostSettings}
|
||||||
/>
|
/>
|
||||||
<FooterIconButton
|
|
||||||
onPress={handleOpenProject}
|
|
||||||
testID="sidebar-add-project"
|
|
||||||
label={labels.addProject}
|
|
||||||
icon={FolderPlus}
|
|
||||||
shortcutKeys={newAgentKeys}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleHome}
|
onPress={handleHome}
|
||||||
testID="sidebar-home"
|
testID="sidebar-home"
|
||||||
@@ -924,9 +980,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarHeaderGroup: {
|
sidebarHeaderGroup: {
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
gap: 2,
|
gap: 2,
|
||||||
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
|
|
||||||
// uses a slightly smaller paddingTop to balance the action buttons' centering
|
|
||||||
// offset so the divider reads as visually centered between the two.
|
|
||||||
paddingBottom: theme.spacing[1.5],
|
paddingBottom: theme.spacing[1.5],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
@@ -1008,8 +1061,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarFooter: {
|
sidebarFooter: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-start",
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
@@ -1020,6 +1073,30 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
footerAddProjectButton: {
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 32,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1.5],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
footerAddProjectButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
footerAddProjectLabel: {
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
footerAddProjectLabelHovered: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
footerIconButton: {
|
footerIconButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -569,8 +569,26 @@ export function createSharedMarkdownRules(): RenderRules {
|
|||||||
{children}
|
{children}
|
||||||
</MarkdownInheritedText>
|
</MarkdownInheritedText>
|
||||||
),
|
),
|
||||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
hardbreak: (
|
||||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||||
|
{"\n"}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
),
|
||||||
|
softbreak: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||||
|
{"\n"}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
),
|
||||||
code_block: (
|
code_block: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||||
|
|
||||||
|
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||||
|
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
|
||||||
|
function BoundMaterialFileIcon({ size }: PanelIconProps) {
|
||||||
|
return <MaterialFileIcon fileName={fileName} size={size} />;
|
||||||
|
}
|
||||||
|
return BoundMaterialFileIcon;
|
||||||
|
}
|
||||||
@@ -1689,10 +1689,29 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
// 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
|
// 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").
|
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
|
||||||
// Android keep the same "\n" they rendered before.
|
// the resolved break styles: hardbreak is a full-width flex-row child on
|
||||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
// Android, and dropping that width joins the surrounding text spans.
|
||||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
hardbreak: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||||
|
{"\n"}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
),
|
||||||
|
softbreak: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||||
|
{"\n"}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
),
|
||||||
code_block: (
|
code_block: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||||
|
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||||
|
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -1632,6 +1634,12 @@ function ProjectBlock({
|
|||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
}) {
|
}) {
|
||||||
|
const {
|
||||||
|
visibleItems: visibleWorkspaces,
|
||||||
|
expanded: workspacesExpanded,
|
||||||
|
canToggle: canToggleWorkspaces,
|
||||||
|
toggleExpanded: toggleWorkspacesExpanded,
|
||||||
|
} = useLimitedSidebarGroup(project.workspaces);
|
||||||
const rowModel = useMemo(
|
const rowModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSidebarProjectRowModel({
|
buildSidebarProjectRowModel({
|
||||||
@@ -1784,19 +1792,28 @@ function ProjectBlock({
|
|||||||
if (!collapsed) {
|
if (!collapsed) {
|
||||||
if (project.workspaces.length > 0) {
|
if (project.workspaces.length > 0) {
|
||||||
projectChildren = (
|
projectChildren = (
|
||||||
<DraggableList
|
<>
|
||||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
<DraggableList
|
||||||
data={project.workspaces}
|
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||||
keyExtractor={workspaceKeyExtractor}
|
data={visibleWorkspaces}
|
||||||
renderItem={renderWorkspace}
|
keyExtractor={workspaceKeyExtractor}
|
||||||
onDragEnd={handleWorkspaceDragEnd}
|
renderItem={renderWorkspace}
|
||||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
onDragEnd={handleWorkspaceDragEnd}
|
||||||
scrollEnabled={false}
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||||
useDragHandle
|
scrollEnabled={false}
|
||||||
nestable={useNestable}
|
useDragHandle
|
||||||
simultaneousGestureRef={parentGestureRef}
|
nestable={useNestable}
|
||||||
containerStyle={styles.workspaceListContainer}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
/>
|
containerStyle={styles.workspaceListContainer}
|
||||||
|
/>
|
||||||
|
{canToggleWorkspaces ? (
|
||||||
|
<SidebarGroupToggleRow
|
||||||
|
expanded={workspacesExpanded}
|
||||||
|
onPress={toggleWorkspacesExpanded}
|
||||||
|
testID={`sidebar-project-show-more-${project.projectKey}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||||
projectChildren = (
|
projectChildren = (
|
||||||
@@ -2073,6 +2090,12 @@ function ProjectModeList({
|
|||||||
const selectionEnabled = isWorkspaceRoute;
|
const selectionEnabled = isWorkspaceRoute;
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||||
|
const {
|
||||||
|
visibleItems: visiblePinnedChats,
|
||||||
|
expanded: pinnedChatsExpanded,
|
||||||
|
canToggle: canTogglePinnedChats,
|
||||||
|
toggleExpanded: togglePinnedChatsExpanded,
|
||||||
|
} = useLimitedSidebarGroup(pinnedChats);
|
||||||
const projectIconTargets = useMemo(
|
const projectIconTargets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projects.flatMap((project) => {
|
projects.flatMap((project) => {
|
||||||
@@ -2303,7 +2326,6 @@ function ProjectModeList({
|
|||||||
canCopyBranchName={workspace.projectKind === "git"}
|
canCopyBranchName={workspace.projectKind === "git"}
|
||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
reserveIdleStatusIndicatorSpace={false}
|
|
||||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||||
selectionEnabled={selectionEnabled}
|
selectionEnabled={selectionEnabled}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
@@ -2331,7 +2353,18 @@ function ProjectModeList({
|
|||||||
{pinnedChats.length > 0 ? (
|
{pinnedChats.length > 0 ? (
|
||||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
{pinnedCollapsed ? null : (
|
||||||
|
<>
|
||||||
|
{visiblePinnedChats.map(renderPinnedChat)}
|
||||||
|
{canTogglePinnedChats ? (
|
||||||
|
<SidebarGroupToggleRow
|
||||||
|
expanded={pinnedChatsExpanded}
|
||||||
|
onPress={togglePinnedChatsExpanded}
|
||||||
|
testID="sidebar-pinned-show-more"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||||
@@ -2399,7 +2432,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingTop: theme.spacing[2],
|
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||||
|
// Schedules icon across the divider; their layout boxes have different insets.
|
||||||
|
paddingTop: 2,
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
projectListContainer: {
|
projectListContainer: {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function PinnedSectionHeader({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
header: {
|
header: {
|
||||||
|
minHeight: 36,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
});
|
||||||
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
|
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||||
|
|
||||||
|
export function SidebarGroupToggleRow({
|
||||||
|
expanded,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
expanded: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
testID: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = t(
|
||||||
|
expanded ? "sidebar.workspace.actions.showLess" : "sidebar.workspace.actions.showMore",
|
||||||
|
);
|
||||||
|
const rowStyle = useCallback(
|
||||||
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.row,
|
||||||
|
(Boolean(hovered) || pressed) && styles.rowHovered,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole={isWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
onPress={onPress}
|
||||||
|
style={rowStyle}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{({ hovered, pressed }) => (
|
||||||
|
<>
|
||||||
|
<View style={styles.iconSlot}>
|
||||||
|
{expanded ? (
|
||||||
|
<ThemedChevronUp
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThemedChevronDown
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
row: {
|
||||||
|
minHeight: 32,
|
||||||
|
marginLeft: theme.spacing[6],
|
||||||
|
marginRight: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
userSelect: "none",
|
||||||
|
},
|
||||||
|
rowHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
iconSlot: {
|
||||||
|
width: theme.iconSize.sm,
|
||||||
|
height: theme.iconSize.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
textHovered: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||||
|
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||||
|
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||||
|
|
||||||
// Themed icon wrappers
|
// Themed icon wrappers
|
||||||
@@ -93,6 +95,12 @@ export function SidebarStatusWorkspaceList({
|
|||||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.togglePinnedCollapsed,
|
(state) => state.togglePinnedCollapsed,
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
visibleItems: visiblePinnedWorkspaces,
|
||||||
|
expanded: pinnedWorkspacesExpanded,
|
||||||
|
canToggle: canTogglePinnedWorkspaces,
|
||||||
|
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||||
|
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||||
|
|
||||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||||
const content = (
|
const content = (
|
||||||
@@ -100,9 +108,9 @@ export function SidebarStatusWorkspaceList({
|
|||||||
{pinnedWorkspaces.length > 0 ? (
|
{pinnedWorkspaces.length > 0 ? (
|
||||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||||
{pinnedCollapsed
|
{pinnedCollapsed ? null : (
|
||||||
? null
|
<>
|
||||||
: pinnedWorkspaces.map((workspace) => (
|
{visiblePinnedWorkspaces.map((workspace) => (
|
||||||
<StatusWorkspaceRow
|
<StatusWorkspaceRow
|
||||||
key={workspace.workspaceKey}
|
key={workspace.workspaceKey}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
@@ -116,10 +124,18 @@ export function SidebarStatusWorkspaceList({
|
|||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
reserveIdleStatusIndicatorSpace={false}
|
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{canTogglePinnedWorkspaces ? (
|
||||||
|
<SidebarGroupToggleRow
|
||||||
|
expanded={pinnedWorkspacesExpanded}
|
||||||
|
onPress={togglePinnedWorkspacesExpanded}
|
||||||
|
testID="sidebar-pinned-show-more"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{listHeaderComponent}
|
{listHeaderComponent}
|
||||||
@@ -189,38 +205,92 @@ function StatusGroupList({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
<StatusGroupRows
|
||||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
key={group.bucket}
|
||||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
group={group}
|
||||||
<View
|
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||||
style={styles.statusWorkspaceListContainer}
|
projectNamesByKey={projectNamesByKey}
|
||||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
shortcutIndex={shortcutIndex}
|
||||||
>
|
showShortcutBadges={showShortcutBadges}
|
||||||
{group.rows.map((workspace) => (
|
onWorkspacePress={onWorkspacePress}
|
||||||
<StatusWorkspaceRow
|
hostLabelByServerId={hostLabelByServerId}
|
||||||
key={workspace.workspaceKey}
|
showHostLabels={showHostLabels}
|
||||||
workspace={workspace}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
subtitle={buildStatusRowSubtitle({
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
/>
|
||||||
hostLabel: showHostLabels
|
|
||||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
|
||||||
: null,
|
|
||||||
})}
|
|
||||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
|
||||||
showShortcutBadge={showShortcutBadges}
|
|
||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
||||||
onWorkspacePress={onWorkspacePress}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusGroupRows({
|
||||||
|
group,
|
||||||
|
collapsed,
|
||||||
|
projectNamesByKey,
|
||||||
|
shortcutIndex,
|
||||||
|
showShortcutBadges,
|
||||||
|
onWorkspacePress,
|
||||||
|
hostLabelByServerId,
|
||||||
|
showHostLabels,
|
||||||
|
supportsPinningByServerId,
|
||||||
|
onToggleWorkspacePin,
|
||||||
|
}: {
|
||||||
|
group: StatusGroup;
|
||||||
|
collapsed: boolean;
|
||||||
|
projectNamesByKey: Map<string, string>;
|
||||||
|
shortcutIndex: Map<string, number>;
|
||||||
|
showShortcutBadges: boolean;
|
||||||
|
onWorkspacePress?: () => void;
|
||||||
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
|
showHostLabels: boolean;
|
||||||
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
visibleItems: visibleWorkspaces,
|
||||||
|
expanded: workspacesExpanded,
|
||||||
|
canToggle: canToggleWorkspaces,
|
||||||
|
toggleExpanded: toggleWorkspacesExpanded,
|
||||||
|
} = useLimitedSidebarGroup(group.rows);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.statusGroupBlock}>
|
||||||
|
<StatusGroupHeader group={group} collapsed={collapsed} />
|
||||||
|
{!collapsed ? (
|
||||||
|
<View
|
||||||
|
style={styles.statusWorkspaceListContainer}
|
||||||
|
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||||
|
>
|
||||||
|
{visibleWorkspaces.map((workspace) => (
|
||||||
|
<StatusWorkspaceRow
|
||||||
|
key={workspace.workspaceKey}
|
||||||
|
workspace={workspace}
|
||||||
|
subtitle={buildStatusRowSubtitle({
|
||||||
|
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||||
|
hostLabel: showHostLabels
|
||||||
|
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||||
|
: null,
|
||||||
|
})}
|
||||||
|
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||||
|
showShortcutBadge={showShortcutBadges}
|
||||||
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
|
onWorkspacePress={onWorkspacePress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{canToggleWorkspaces ? (
|
||||||
|
<SidebarGroupToggleRow
|
||||||
|
expanded={workspacesExpanded}
|
||||||
|
onPress={toggleWorkspacesExpanded}
|
||||||
|
testID={`sidebar-status-show-more-${group.bucket}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Status mode breaks the project grouping, so the row needs the project name to stay
|
// Status mode breaks the project grouping, so the row needs the project name to stay
|
||||||
// legible; the host is appended after a middle dot once labels are active.
|
// legible; the host is appended after a middle dot once labels are active.
|
||||||
function buildStatusRowSubtitle({
|
function buildStatusRowSubtitle({
|
||||||
@@ -723,7 +793,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingTop: theme.spacing[2],
|
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||||
|
paddingTop: 2,
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
pinnedSection: {
|
pinnedSection: {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE_ITEMS = 20;
|
||||||
|
|
||||||
|
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||||
|
[expanded, items],
|
||||||
|
);
|
||||||
|
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||||
|
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||||
|
|
||||||
|
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
getWorkspacePaneDescriptors,
|
getWorkspacePaneDescriptors,
|
||||||
} from "@/screens/workspace/workspace-pane-state";
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||||
|
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
|
||||||
import {
|
import {
|
||||||
WorkspacePaneContent,
|
WorkspacePaneContent,
|
||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
@@ -75,7 +76,7 @@ import {
|
|||||||
type SplitPane,
|
type SplitPane,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
@@ -934,11 +935,17 @@ function SplitPaneView({
|
|||||||
);
|
);
|
||||||
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||||
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
||||||
|
const modifiedPaneTabIds = useModifiedPanelTabIds({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabIds: paneTabIds,
|
||||||
|
});
|
||||||
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
||||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||||
const { mountedTabIds } = useMountedTabSet({
|
const { mountedTabIds } = useMountedTabSet({
|
||||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
activeTabId: activeTabDescriptor?.tabId ?? null,
|
||||||
allTabIds: paneTabIds,
|
allTabIds: paneTabIds,
|
||||||
|
retainedTabIds: modifiedPaneTabIds,
|
||||||
cap: 3,
|
cap: 3,
|
||||||
});
|
});
|
||||||
const mountedPaneTabIds = useMemo(
|
const mountedPaneTabIds = useMemo(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|||||||
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||||
// drift apart.
|
// drift apart.
|
||||||
export const TREE_INDENT_PER_LEVEL = 16;
|
export const TREE_INDENT_PER_LEVEL = 16;
|
||||||
|
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||||
|
|
||||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||||
* in the Changes tree so their indentation can't drift apart. */
|
* in the Changes tree so their indentation can't drift apart. */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const theme = {
|
|||||||
md: 6,
|
md: 6,
|
||||||
lg: 8,
|
lg: 8,
|
||||||
xl: 12,
|
xl: 12,
|
||||||
|
full: 9999,
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
1: 1,
|
1: 1,
|
||||||
@@ -102,12 +103,41 @@ describe("control geometry", () => {
|
|||||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||||
const geometry = createControlGeometry(theme);
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
expect(geometry.segmentedContainerXs.padding).toBe(0);
|
||||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
expect(geometry.segmentedContainerSm.padding).toBe(0);
|
||||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
|
||||||
|
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||||
|
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
|
||||||
|
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
|
||||||
|
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
|
||||||
|
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one size contract across buttons and segmented controls", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
// xs is a genuinely smaller tier, not sm with a different font.
|
||||||
|
expect(geometry.buttonXs.minHeight).toBe(28);
|
||||||
|
expect(geometry.buttonSm.minHeight).toBe(32);
|
||||||
|
expect(geometry.buttonMd.minHeight).toBe(44);
|
||||||
|
|
||||||
|
// Same size name means the same label size on every control kind.
|
||||||
|
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
|
||||||
|
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
|
||||||
|
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
|
||||||
|
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
|
||||||
|
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
|
||||||
|
|
||||||
|
// Same size name means the same horizontal padding on every control kind.
|
||||||
|
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
|
||||||
|
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
|
||||||
|
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|||||||
|
|
||||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||||
export type FieldControlSize = "sm" | "md";
|
export type FieldControlSize = "sm" | "md";
|
||||||
export type SegmentedControlSize = "sm" | "md";
|
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||||
|
|
||||||
export interface ControlInteractionState {
|
export interface ControlInteractionState {
|
||||||
@@ -22,8 +22,10 @@ export interface ControlInteractionStyleMap {
|
|||||||
controlDisabled?: StyleProp<ViewStyle>;
|
controlDisabled?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIGHT_CONTROL_HEIGHT = 28;
|
||||||
const COMPACT_CONTROL_HEIGHT = 32;
|
const COMPACT_CONTROL_HEIGHT = 32;
|
||||||
const FIELD_CONTROL_HEIGHT = 44;
|
const FIELD_CONTROL_HEIGHT = 44;
|
||||||
|
const SEGMENTED_TIGHT_INSET = 2;
|
||||||
const SEGMENTED_COMPACT_INSET = 2;
|
const SEGMENTED_COMPACT_INSET = 2;
|
||||||
const SEGMENTED_FIELD_INSET = 3;
|
const SEGMENTED_FIELD_INSET = 3;
|
||||||
const SWITCH_TRACK_WIDTH = 34;
|
const SWITCH_TRACK_WIDTH = 34;
|
||||||
@@ -35,6 +37,7 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
|||||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||||
|
|
||||||
const controlHeights = {
|
const controlHeights = {
|
||||||
|
tight: TIGHT_CONTROL_HEIGHT,
|
||||||
compact: COMPACT_CONTROL_HEIGHT,
|
compact: COMPACT_CONTROL_HEIGHT,
|
||||||
field: FIELD_CONTROL_HEIGHT,
|
field: FIELD_CONTROL_HEIGHT,
|
||||||
};
|
};
|
||||||
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||||
|
xs: ICON_SIZE.xs,
|
||||||
sm: ICON_SIZE.sm,
|
sm: ICON_SIZE.sm,
|
||||||
md: ICON_SIZE.md,
|
md: ICON_SIZE.md,
|
||||||
};
|
};
|
||||||
@@ -58,10 +62,6 @@ export const switchGeometry = {
|
|||||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||||
};
|
};
|
||||||
|
|
||||||
function nestedRadius(containerRadius: number, inset: number): number {
|
|
||||||
return Math.max(0, containerRadius - inset);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLineHeight(fontSize: number): number {
|
function fieldLineHeight(fontSize: number): number {
|
||||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,6 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: fieldTextMdLineHeight,
|
lineHeight: fieldTextMdLineHeight,
|
||||||
};
|
};
|
||||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
|
||||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
|
||||||
const switchControl = {
|
const switchControl = {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.compact,
|
||||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||||
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
buttonXs: {
|
buttonXs: {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.tight,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
switchControl,
|
switchControl,
|
||||||
|
segmentedContainerXs: {
|
||||||
|
minHeight: controlHeights.tight,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
segmentedContainerSm: {
|
segmentedContainerSm: {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.compact,
|
||||||
padding: SEGMENTED_COMPACT_INSET,
|
padding: 0,
|
||||||
borderRadius: segmentedContainerSmRadius,
|
|
||||||
},
|
},
|
||||||
segmentedContainerMd: {
|
segmentedContainerMd: {
|
||||||
minHeight: controlHeights.field,
|
minHeight: controlHeights.field,
|
||||||
padding: SEGMENTED_FIELD_INSET,
|
padding: 0,
|
||||||
borderRadius: segmentedContainerMdRadius,
|
},
|
||||||
|
segmentedSegmentXs: {
|
||||||
|
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
segmentedSegmentSm: {
|
segmentedSegmentSm: {
|
||||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
segmentedSegmentMd: {
|
segmentedSegmentMd: {
|
||||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[4],
|
||||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
segmentedLabelXs: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
segmentedLabelSm: {
|
segmentedLabelSm: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
segmentedLabelMd: {
|
segmentedLabelMd: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -33,6 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||||
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
|
|
||||||
// Action status for menu items with loading/success feedback
|
// Action status for menu items with loading/success feedback
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -216,12 +218,16 @@ function renderDropdownSurface(input: {
|
|||||||
style={surfaceStyle}
|
style={surfaceStyle}
|
||||||
frameStyle={frameStyle}
|
frameStyle={frameStyle}
|
||||||
entering={contentEntering}
|
entering={contentEntering}
|
||||||
exiting={contentExiting.withCallback((finished) => {
|
exiting={
|
||||||
"worklet";
|
isWeb
|
||||||
if (finished) {
|
? undefined
|
||||||
runOnJS(onExited)();
|
: contentExiting.withCallback((finished) => {
|
||||||
}
|
"worklet";
|
||||||
})}
|
if (finished) {
|
||||||
|
runOnJS(onExited)();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</FloatingSurface>
|
</FloatingSurface>
|
||||||
@@ -496,6 +502,17 @@ export function DropdownMenuContent({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [setOpen]);
|
}, [setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.stopPropagation();
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
}, [handleClose, modalVisible]);
|
||||||
|
|
||||||
// Measure trigger when opening
|
// Measure trigger when opening
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !triggerRef.current) {
|
if (!open || !triggerRef.current) {
|
||||||
@@ -613,6 +630,34 @@ export function DropdownMenuContent({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const overlay = (
|
||||||
|
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("menu.backdrop")}
|
||||||
|
style={styles.backdrop}
|
||||||
|
onPress={handleClose}
|
||||||
|
testID={testID ? `${testID}-backdrop` : undefined}
|
||||||
|
/>
|
||||||
|
{!closing
|
||||||
|
? renderDropdownSurface({
|
||||||
|
frameStyle,
|
||||||
|
testID,
|
||||||
|
surfaceStyle,
|
||||||
|
scrollable,
|
||||||
|
scrollViewportStyle,
|
||||||
|
content,
|
||||||
|
surfaceNativeID,
|
||||||
|
onExited: () => setModalVisible(false),
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isWeb && typeof document !== "undefined") {
|
||||||
|
return createPortal(overlay, getOverlayRoot());
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
@@ -622,27 +667,7 @@ export function DropdownMenuContent({
|
|||||||
onDismiss={flushPendingSelect}
|
onDismiss={flushPendingSelect}
|
||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
{overlay}
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("menu.backdrop")}
|
|
||||||
style={styles.backdrop}
|
|
||||||
onPress={handleClose}
|
|
||||||
testID={testID ? `${testID}-backdrop` : undefined}
|
|
||||||
/>
|
|
||||||
{!closing
|
|
||||||
? renderDropdownSurface({
|
|
||||||
frameStyle,
|
|
||||||
testID,
|
|
||||||
surfaceStyle,
|
|
||||||
scrollable,
|
|
||||||
scrollViewportStyle,
|
|
||||||
content,
|
|
||||||
surfaceNativeID,
|
|
||||||
onExited: () => setModalVisible(false),
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -878,6 +903,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
overlayWeb: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: OVERLAY_Z.modal,
|
||||||
|
pointerEvents: "auto" as const,
|
||||||
|
},
|
||||||
backdrop: {
|
backdrop: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
|||||||
|
|
||||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||||
|
|
||||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
|
|||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
const sizeStyles = {
|
||||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||||
|
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
|
||||||
|
}[size];
|
||||||
|
const containerSizeStyle = sizeStyles.container;
|
||||||
|
const segmentSizeStyle = sizeStyles.segment;
|
||||||
|
const labelSizeStyle = sizeStyles.label;
|
||||||
const iconSize = segmentedIconSize[size];
|
const iconSize = segmentedIconSize[size];
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
return {
|
return {
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "stretch",
|
alignItems: "center",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: "transparent",
|
||||||
gap: 2,
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
containerXs: {
|
||||||
|
...geometry.segmentedContainerXs,
|
||||||
},
|
},
|
||||||
containerSm: {
|
containerSm: {
|
||||||
...geometry.segmentedContainerSm,
|
...geometry.segmentedContainerSm,
|
||||||
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
segmentXs: {
|
||||||
|
...geometry.segmentedSegmentXs,
|
||||||
|
},
|
||||||
segmentSm: {
|
segmentSm: {
|
||||||
...geometry.segmentedSegmentSm,
|
...geometry.segmentedSegmentSm,
|
||||||
},
|
},
|
||||||
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
...geometry.segmentedSegmentMd,
|
...geometry.segmentedSegmentMd,
|
||||||
},
|
},
|
||||||
segmentSelected: {
|
segmentSelected: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.foreground,
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
},
|
||||||
segmentHover: {
|
segmentHover: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
segmentPressed: {
|
segmentPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
segmentDisabled: {
|
segmentDisabled: {
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
labelXs: {
|
||||||
|
...geometry.segmentedLabelXs,
|
||||||
|
},
|
||||||
labelSm: {
|
labelSm: {
|
||||||
...geometry.segmentedLabelSm,
|
...geometry.segmentedLabelSm,
|
||||||
},
|
},
|
||||||
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
...geometry.segmentedLabelMd,
|
...geometry.segmentedLabelMd,
|
||||||
},
|
},
|
||||||
labelSelected: {
|
labelSelected: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -23,13 +24,12 @@ import {
|
|||||||
type StyleProp,
|
type StyleProp,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Portal } from "@gorhom/portal";
|
|
||||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
|
||||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { FloatingSurface } from "@/components/ui/floating";
|
import { FloatingSurface } from "@/components/ui/floating";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
|
|
||||||
type Side = "top" | "bottom" | "left" | "right";
|
type Side = "top" | "bottom" | "left" | "right";
|
||||||
type Align = "start" | "center" | "end";
|
type Align = "start" | "center" | "end";
|
||||||
@@ -446,7 +446,6 @@ export function TooltipContent({
|
|||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const ctx = useTooltipContext("TooltipContent");
|
const ctx = useTooltipContext("TooltipContent");
|
||||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
|
||||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
@@ -516,23 +515,22 @@ export function TooltipContent({
|
|||||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return createPortal(
|
||||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
<FloatingSurface
|
||||||
<FloatingSurface
|
pointerEvents="none"
|
||||||
pointerEvents="none"
|
entering={FadeIn.duration(80)}
|
||||||
entering={FadeIn.duration(80)}
|
exiting={FadeOut.duration(80)}
|
||||||
exiting={FadeOut.duration(80)}
|
collapsable={false}
|
||||||
collapsable={false}
|
testID={testID}
|
||||||
testID={testID}
|
onLayout={handleLayout}
|
||||||
onLayout={handleLayout}
|
style={contentStyle}
|
||||||
style={contentStyle}
|
frameStyle={frameStyle}
|
||||||
frameStyle={frameStyle}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</FloatingSurface>
|
||||||
</FloatingSurface>
|
</View>,
|
||||||
</View>
|
getOverlayRoot(),
|
||||||
</Portal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: 1000,
|
zIndex: OVERLAY_Z.tooltip,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
|||||||
input.setLightboxMetadata(input.attachment.metadata);
|
input.setLightboxMetadata(input.attachment.metadata);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (input.attachment.kind === "file") {
|
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isWorkspaceAttachment(input.attachment)) {
|
if (isWorkspaceAttachment(input.attachment)) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildForgeAttachmentFromSearchItem,
|
buildForgeAttachmentFromSearchItem,
|
||||||
buildLegacyGitHubAttachmentFromSearchItem,
|
buildLegacyGitHubAttachmentFromSearchItem,
|
||||||
} from "@/utils/review-attachments";
|
} from "@/utils/review-attachments";
|
||||||
|
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||||
|
|
||||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||||
|
|
||||||
@@ -49,6 +50,11 @@ export function splitComposerAttachmentsForSubmit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.kind === "workspace_file") {
|
||||||
|
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isWorkspaceAttachment(attachment)) {
|
if (isWorkspaceAttachment(attachment)) {
|
||||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||||
images.push(attachment.attachment.screenshot);
|
images.push(attachment.attachment.screenshot);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
|
|
||||||
const { asyncStorage } = vi.hoisted(() => ({
|
const { asyncStorage } = vi.hoisted(() => ({
|
||||||
asyncStorage: new Map<string, string>(),
|
asyncStorage: new Map<string, string>(),
|
||||||
@@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
useDraftStore.setState({
|
||||||
|
drafts: {},
|
||||||
|
createModalDraft: null,
|
||||||
|
attachmentFocusRequestByDraftKey: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
||||||
@@ -424,6 +429,39 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
||||||
|
const image: AttachmentMetadata = {
|
||||||
|
id: "legacy-image",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachments/legacy-image",
|
||||||
|
createdAt: 10,
|
||||||
|
};
|
||||||
|
useDraftStore.setState({
|
||||||
|
drafts: {
|
||||||
|
"draft:legacy-workspace-file": {
|
||||||
|
input: { text: "legacy text", images: [image] },
|
||||||
|
lifecycle: "active",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
version: 1,
|
||||||
|
} as unknown as DraftRecordForTest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDraftStore.getState().attachWorkspaceFile({
|
||||||
|
draftKey: "draft:legacy-workspace-file",
|
||||||
|
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
||||||
|
text: "legacy text",
|
||||||
|
attachments: [
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("clear resets text and attachments", async () => {
|
it("clear resets text and attachments", async () => {
|
||||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
const image: AttachmentMetadata = {
|
const image: AttachmentMetadata = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import {
|
import {
|
||||||
areAttachmentsEqual,
|
|
||||||
buildDraftAgentControls,
|
buildDraftAgentControls,
|
||||||
hasDraftContent,
|
hasDraftContent,
|
||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
type ProviderSelectionState,
|
type ProviderSelectionState,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { toDraftInputIfReady } from "@/stores/draft-store/state";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -57,6 +57,7 @@ export interface AgentInputDraft {
|
|||||||
setAttachments: (updater: AttachmentUpdater) => void;
|
setAttachments: (updater: AttachmentUpdater) => void;
|
||||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
|
attachmentFocusRequestId: number;
|
||||||
composerState: DraftComposerState | null;
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
}),
|
}),
|
||||||
[formState.selectedServerId, input.draftKey],
|
[formState.selectedServerId, input.draftKey],
|
||||||
);
|
);
|
||||||
const [text, setText] = useState("");
|
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
|
||||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const attachmentFocusRequestId = useDraftStore(
|
||||||
const draftGenerationRef = useRef(0);
|
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
||||||
const hydratedGenerationRef = useRef(0);
|
);
|
||||||
|
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||||
|
const text = draft?.text ?? "";
|
||||||
|
const attachments = draft?.attachments ?? [];
|
||||||
|
const isHydrated = hydratedDraftKey === draftKey;
|
||||||
|
|
||||||
const setAttachments = useCallback((updater: AttachmentUpdater) => {
|
const saveDraft = useCallback(
|
||||||
setAttachmentsState((previousAttachments) => {
|
(
|
||||||
if (typeof updater === "function") {
|
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
|
||||||
return updater(previousAttachments);
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const store = useDraftStore.getState();
|
||||||
|
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
|
||||||
|
const next = update(current);
|
||||||
|
if (!hasDraftContent(next)) {
|
||||||
|
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return updater;
|
store.saveDraftInput({ draftKey, draft: next });
|
||||||
});
|
},
|
||||||
}, []);
|
[draftKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setText = useCallback(
|
||||||
|
(nextText: string) => {
|
||||||
|
saveDraft((current) => ({ ...current, text: nextText }));
|
||||||
|
},
|
||||||
|
[saveDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAttachments = useCallback(
|
||||||
|
(updater: AttachmentUpdater) => {
|
||||||
|
saveDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[saveDraft],
|
||||||
|
);
|
||||||
|
|
||||||
const clear = useCallback(
|
const clear = useCallback(
|
||||||
(lifecycle: "sent" | "abandoned") => {
|
(lifecycle: "sent" | "abandoned") => {
|
||||||
const store = useDraftStore.getState();
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||||
store.clearDraftInput({ draftKey, lifecycle });
|
|
||||||
|
|
||||||
const generation = store.beginDraftGeneration(draftKey);
|
|
||||||
draftGenerationRef.current = generation;
|
|
||||||
hydratedGenerationRef.current = generation;
|
|
||||||
|
|
||||||
setText("");
|
|
||||||
setAttachmentsState([]);
|
|
||||||
setIsHydrated(true);
|
|
||||||
},
|
},
|
||||||
[draftKey],
|
[draftKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = useDraftStore.getState();
|
|
||||||
const generation = store.beginDraftGeneration(draftKey);
|
|
||||||
draftGenerationRef.current = generation;
|
|
||||||
hydratedGenerationRef.current = 0;
|
|
||||||
|
|
||||||
setText("");
|
|
||||||
setAttachmentsState([]);
|
|
||||||
setIsHydrated(false);
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const draft = await store.hydrateDraftInput({
|
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||||
draftKey,
|
if (!cancelled) {
|
||||||
});
|
setHydratedDraftKey(draftKey);
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draft) {
|
|
||||||
setText(draft.text);
|
|
||||||
setAttachmentsState(draft.attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
hydratedGenerationRef.current = generation;
|
|
||||||
setIsHydrated(true);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -145,52 +145,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
};
|
};
|
||||||
}, [draftKey]);
|
}, [draftKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentGeneration = draftGenerationRef.current;
|
|
||||||
if (currentGeneration <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = useDraftStore.getState();
|
|
||||||
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
|
||||||
draftKey,
|
|
||||||
generation: currentGeneration,
|
|
||||||
});
|
|
||||||
if (!isCurrentGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hydratedGenerationRef.current !== currentGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = store.getDraftInput(draftKey);
|
|
||||||
const isSameDraft =
|
|
||||||
existing !== undefined &&
|
|
||||||
existing.text === text &&
|
|
||||||
areAttachmentsEqual({
|
|
||||||
left: existing.attachments,
|
|
||||||
right: attachments,
|
|
||||||
});
|
|
||||||
if (isSameDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDraftContent({ text, attachments })) {
|
|
||||||
if (existing) {
|
|
||||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.saveDraftInput({
|
|
||||||
draftKey,
|
|
||||||
draft: {
|
|
||||||
text,
|
|
||||||
attachments,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [attachments, draftKey, text]);
|
|
||||||
|
|
||||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!composerOptions || !lockedWorkingDir) {
|
if (!composerOptions || !lockedWorkingDir) {
|
||||||
@@ -304,6 +258,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
setAttachments,
|
setAttachments,
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
|
attachmentFocusRequestId,
|
||||||
composerState,
|
composerState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
useIsCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||||
@@ -665,7 +665,11 @@ export function WorkspaceDraftAgentTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inputAreaWrapperStyle = useMemo(
|
const inputAreaWrapperStyle = useMemo(
|
||||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
|
() => [
|
||||||
|
animatedStaticStyles.inputAreaWrapper,
|
||||||
|
{ paddingBottom: insets.bottom },
|
||||||
|
composerKeyboardStyle,
|
||||||
|
],
|
||||||
[insets.bottom, composerKeyboardStyle],
|
[insets.bottom, composerKeyboardStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -747,6 +751,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
externalKeyboardShift
|
externalKeyboardShift
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
@@ -761,6 +766,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
cwd={composerState.workingDir}
|
cwd={composerState.workingDir}
|
||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
|
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||||
onFocusInput={handleFocusInputCallback}
|
onFocusInput={handleFocusInputCallback}
|
||||||
commandDraftConfig={composerState.commandDraftConfig}
|
commandDraftConfig={composerState.commandDraftConfig}
|
||||||
agentControls={composerAgentControls}
|
agentControls={composerAgentControls}
|
||||||
@@ -772,6 +778,12 @@ export function WorkspaceDraftAgentTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const animatedStaticStyles = RNStyleSheet.create({
|
||||||
|
inputAreaWrapper: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -795,10 +807,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
configSection: {
|
configSection: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
inputAreaWrapper: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
importPillRow: {
|
importPillRow: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
|
import { resolveFocusedChatTarget } from "./focused-chat-target";
|
||||||
|
|
||||||
|
function layoutWithTarget(
|
||||||
|
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
|
||||||
|
): WorkspaceLayout {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "pane-1",
|
||||||
|
tabIds: ["focused-tab"],
|
||||||
|
focusedTabId: "focused-tab",
|
||||||
|
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focusedPaneId: "pane-1",
|
||||||
|
} as WorkspaceLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("focused chat target", () => {
|
||||||
|
it("targets the focused agent draft", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
|
||||||
|
}),
|
||||||
|
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets the focused unsent draft", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
|
||||||
|
}),
|
||||||
|
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not guess when the focused tab is not a chat", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
|
import {
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
type WorkspaceLayout,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
|
export interface FocusedChatTarget {
|
||||||
|
tabId: string;
|
||||||
|
draftKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFocusedChatTarget(input: {
|
||||||
|
serverId: string;
|
||||||
|
layout: WorkspaceLayout | undefined;
|
||||||
|
}): FocusedChatTarget | null {
|
||||||
|
if (!input.layout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||||
|
const focusedTabId = pane?.focusedTabId;
|
||||||
|
if (!focusedTabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tab = collectAllTabs(input.layout.root).find(
|
||||||
|
(candidate) => candidate.tabId === focusedTabId,
|
||||||
|
);
|
||||||
|
if (!tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "agent") {
|
||||||
|
return {
|
||||||
|
tabId: tab.tabId,
|
||||||
|
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "draft") {
|
||||||
|
return {
|
||||||
|
tabId: tab.tabId,
|
||||||
|
draftKey: buildDraftStoreKey({
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: tab.tabId,
|
||||||
|
draftId: tab.target.draftId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -48,7 +48,9 @@ interface SearchCall {
|
|||||||
|
|
||||||
interface HarnessInput {
|
interface HarnessInput {
|
||||||
initialAttachments?: UserComposerAttachment[];
|
initialAttachments?: UserComposerAttachment[];
|
||||||
|
initialCwd?: string;
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
|
onPullRequestDetected?: () => void;
|
||||||
remote?: string | null;
|
remote?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,14 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -87,6 +97,8 @@ function createWrapper() {
|
|||||||
|
|
||||||
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||||
const [text, setText] = useState(input.initialText ?? "");
|
const [text, setText] = useState(input.initialText ?? "");
|
||||||
|
const [searchClient, setSearchClient] = useState(client);
|
||||||
|
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
|
||||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||||
input.initialAttachments ?? [],
|
input.initialAttachments ?? [],
|
||||||
);
|
);
|
||||||
@@ -94,18 +106,22 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
|||||||
text,
|
text,
|
||||||
remoteUrl: input.remote ?? remoteUrl,
|
remoteUrl: input.remote ?? remoteUrl,
|
||||||
attachments,
|
attachments,
|
||||||
client,
|
client: searchClient,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
cwd,
|
cwd: workingDirectory,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
|
onPullRequestDetected: input.onPullRequestDetected,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
setText,
|
setText,
|
||||||
|
setSearchClient,
|
||||||
|
setWorkingDirectory,
|
||||||
attachments,
|
attachments,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
|
isResolving: autoAttach.isResolving,
|
||||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -121,14 +137,20 @@ describe("useComposerGithubAutoAttach", () => {
|
|||||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = createSearchClient([pr101]);
|
const client = createSearchClient([pr101]);
|
||||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
const onPullRequestDetected = vi.fn();
|
||||||
|
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||||
});
|
});
|
||||||
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
|
||||||
await flushDebounce();
|
await flushDebounce();
|
||||||
|
|
||||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -208,4 +230,115 @@ describe("useComposerGithubAutoAttach", () => {
|
|||||||
]);
|
]);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stays resolving while overlapping lookups share a ref", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const firstLookup = deferred<ForgeSearchPayload>();
|
||||||
|
const secondLookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText(
|
||||||
|
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Still https://github.com/acme/paseo/pull/202");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
firstLookup.resolve(githubPayload([], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
secondLookup.resolve(githubPayload([], "search-202"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops resolving when an in-flight PR URL is removed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a lookup that finishes after the target changes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setWorkingDirectory("/other-repo");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
await act(async () => {
|
||||||
|
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.attachments).toEqual([]);
|
||||||
|
expect(client.searchForge).toHaveBeenCalledTimes(1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a lookup after the transport client is replaced for the same target", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const firstClient: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const replacementClient = createSearchClient([pr101]);
|
||||||
|
const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
act(() => {
|
||||||
|
result.current.setSearchClient(replacementClient);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
|
expect(replacementClient.calls).toEqual([]);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
@@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
supportsForgeSearch?: boolean;
|
supportsForgeSearch?: boolean;
|
||||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||||
|
onPullRequestDetected?: () => void;
|
||||||
|
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposerGithubAutoAttachResult {
|
interface ComposerGithubAutoAttachResult {
|
||||||
|
isResolving: boolean;
|
||||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach(
|
|||||||
const latestRef = useRef(params);
|
const latestRef = useRef(params);
|
||||||
const removedRefKeysRef = useRef(new Set<string>());
|
const removedRefKeysRef = useRef(new Set<string>());
|
||||||
const pendingRefKeysRef = useRef(new Set<string>());
|
const pendingRefKeysRef = useRef(new Set<string>());
|
||||||
|
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||||
|
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
||||||
|
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
latestRef.current = params;
|
latestRef.current = params;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
suppressRefsCarriedAcrossTargets({
|
||||||
|
params: latestRef.current,
|
||||||
|
previousTargetRef,
|
||||||
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
|
});
|
||||||
|
notifyNewPullRequestRefs({
|
||||||
|
params: latestRef.current,
|
||||||
|
presentPullRequestKeysRef,
|
||||||
|
});
|
||||||
const refs = refsReadyForLookup({
|
const refs = refsReadyForLookup({
|
||||||
params: latestRef.current,
|
params: latestRef.current,
|
||||||
removedRefKeys: removedRefKeysRef.current,
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
@@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refKeys = refs.map(githubRefKey);
|
||||||
|
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
||||||
|
let resolvingReleased = false;
|
||||||
|
const releaseResolving = () => {
|
||||||
|
if (resolvingReleased) return;
|
||||||
|
resolvingReleased = true;
|
||||||
|
clearResolvingKeys(setResolvingRefCounts, refKeys);
|
||||||
|
};
|
||||||
|
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
void attachRefs({
|
void attachRefs({
|
||||||
refs,
|
refs,
|
||||||
@@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach(
|
|||||||
latestRef,
|
latestRef,
|
||||||
removedRefKeys: removedRefKeysRef.current,
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
pendingRefKeys: pendingRefKeysRef.current,
|
pendingRefKeys: pendingRefKeysRef.current,
|
||||||
});
|
}).finally(releaseResolving);
|
||||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
|
releaseResolving();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
params.text,
|
params.text,
|
||||||
@@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach(
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
isResolving: resolvingRefCounts.size > 0,
|
||||||
markGithubAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
}),
|
}),
|
||||||
[markGithubAttachmentRemoved],
|
[markGithubAttachmentRemoved, resolvingRefCounts.size],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suppressRefsCarriedAcrossTargets({
|
||||||
|
params,
|
||||||
|
previousTargetRef,
|
||||||
|
removedRefKeys,
|
||||||
|
}: {
|
||||||
|
params: ComposerGithubAutoAttachInput;
|
||||||
|
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||||
|
removedRefKeys: Set<string>;
|
||||||
|
}): void {
|
||||||
|
const previous = previousTargetRef.current;
|
||||||
|
const targetChanged =
|
||||||
|
previous.cwd.trim().length > 0 &&
|
||||||
|
params.cwd.trim().length > 0 &&
|
||||||
|
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
|
||||||
|
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||||
|
if (!targetChanged) return;
|
||||||
|
|
||||||
|
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||||
|
removedRefKeys.add(githubRefKey(ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyNewPullRequestRefs({
|
||||||
|
params,
|
||||||
|
presentPullRequestKeysRef,
|
||||||
|
}: {
|
||||||
|
params: ComposerGithubAutoAttachInput;
|
||||||
|
presentPullRequestKeysRef: RefObject<Set<string>>;
|
||||||
|
}): void {
|
||||||
|
const currentKeys = new Set(
|
||||||
|
extractGithubRefs(params.text, params.remoteUrl)
|
||||||
|
.filter((ref) => ref.kind === "pull")
|
||||||
|
.map(githubRefKey),
|
||||||
|
);
|
||||||
|
for (const key of currentKeys) {
|
||||||
|
if (!presentPullRequestKeysRef.current.has(key)) {
|
||||||
|
params.onPullRequestDetected?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presentPullRequestKeysRef.current = currentKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeys(
|
||||||
|
current: ReadonlyMap<string, number>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): ReadonlyMap<string, number> {
|
||||||
|
const nextCounts = new Map(current);
|
||||||
|
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
|
||||||
|
return nextCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeys(
|
||||||
|
current: ReadonlyMap<string, number>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): ReadonlyMap<string, number> {
|
||||||
|
const next = new Map(current);
|
||||||
|
for (const key of keys) {
|
||||||
|
const count = next.get(key) ?? 0;
|
||||||
|
if (count <= 1) next.delete(key);
|
||||||
|
else next.set(key, count - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResolvingKeys(
|
||||||
|
setResolvingRefCounts: Dispatch<SetStateAction<ReadonlyMap<string, number>>>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): void {
|
||||||
|
setResolvingRefCounts((current) => removeKeys(current, keys));
|
||||||
|
}
|
||||||
|
|
||||||
async function attachRefs({
|
async function attachRefs({
|
||||||
refs,
|
refs,
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -142,16 +242,28 @@ async function attachRef({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
const current = latestRef.current;
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
removedRefKeys.has(key) ||
|
||||||
|
!isSameLookupTarget(snapshot, current) ||
|
||||||
|
!isRefStillPresent(ref, current)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
latestRef.current.setAttachments((current) => {
|
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
return;
|
||||||
return current;
|
}
|
||||||
|
current.setAttachments((attachments) => {
|
||||||
|
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
|
||||||
|
return attachments;
|
||||||
}
|
}
|
||||||
return toggleGithubAttachment(current, item);
|
return toggleGithubAttachment(attachments, item);
|
||||||
});
|
});
|
||||||
|
if (item.kind === "change_request") {
|
||||||
|
current.onPullRequestAdded?.(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refsReadyForLookup({
|
function refsReadyForLookup({
|
||||||
@@ -212,6 +324,17 @@ function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameLookupTarget(
|
||||||
|
initial: ComposerGithubAutoAttachInput,
|
||||||
|
current: ComposerGithubAutoAttachInput,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
initial.serverId === current.serverId &&
|
||||||
|
initial.cwd === current.cwd &&
|
||||||
|
initial.remoteUrl === current.remoteUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
@@ -98,6 +99,7 @@ import type {
|
|||||||
AttachmentMetadata,
|
AttachmentMetadata,
|
||||||
ComposerAttachment,
|
ComposerAttachment,
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
|
WorkspaceFileComposerAttachment,
|
||||||
WorkspaceComposerAttachment,
|
WorkspaceComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import type { PickedFile } from "@/attachments/picked-file";
|
import type { PickedFile } from "@/attachments/picked-file";
|
||||||
@@ -118,6 +120,15 @@ import { getForgePresentation } from "@/git/forge";
|
|||||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||||
|
import {
|
||||||
|
appendWorkspaceFileAttachment,
|
||||||
|
getWorkspaceFileAttachmentKey,
|
||||||
|
getWorkspaceFileAttachmentSubtitle,
|
||||||
|
} from "@/attachments/workspace-file";
|
||||||
|
import {
|
||||||
|
resolveWorkspaceFileDrop,
|
||||||
|
type WorkspaceFileDragPayload,
|
||||||
|
} from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
type QueuedMessage = QueuedComposerMessage;
|
type QueuedMessage = QueuedComposerMessage;
|
||||||
|
|
||||||
@@ -398,6 +409,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "workspace_file") {
|
||||||
|
return (
|
||||||
|
<WorkspaceFileAttachmentPill
|
||||||
|
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
|
||||||
|
attachment={attachment}
|
||||||
|
index={index}
|
||||||
|
disabled={disabled}
|
||||||
|
onRemove={onRemove}
|
||||||
|
removeLabel={labels.removeFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
if (composerWorkspaceAttachment.is(attachment)) {
|
||||||
return composerWorkspaceAttachment.renderPill({
|
return composerWorkspaceAttachment.renderPill({
|
||||||
attachment,
|
attachment,
|
||||||
@@ -710,6 +733,43 @@ function FileAttachmentPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFileAttachmentPillProps {
|
||||||
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
|
index: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
removeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceFileAttachmentPill({
|
||||||
|
attachment,
|
||||||
|
index,
|
||||||
|
disabled,
|
||||||
|
onRemove,
|
||||||
|
removeLabel,
|
||||||
|
}: WorkspaceFileAttachmentPillProps) {
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove(index);
|
||||||
|
}, [index, onRemove]);
|
||||||
|
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||||
|
return (
|
||||||
|
<AttachmentPill
|
||||||
|
testID="composer-workspace-file-attachment-pill"
|
||||||
|
onOpen={noopCallback}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
openAccessibilityLabel={fileName}
|
||||||
|
removeAccessibilityLabel={removeLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<AttachmentLabel
|
||||||
|
icon={filePillIcon}
|
||||||
|
title={fileName}
|
||||||
|
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
|
||||||
|
/>
|
||||||
|
</AttachmentPill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface GithubPickerOptionProps {
|
interface GithubPickerOptionProps {
|
||||||
label: string;
|
label: string;
|
||||||
testID: string;
|
testID: string;
|
||||||
@@ -754,6 +814,7 @@ function GithubPickerOption({
|
|||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||||
@@ -768,6 +829,8 @@ interface ComposerProps {
|
|||||||
submitIcon?: "arrow" | "return";
|
submitIcon?: "arrow" | "return";
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
|
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||||
|
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
@@ -777,10 +840,14 @@ interface ComposerProps {
|
|||||||
attachmentScopeKeys?: readonly string[];
|
attachmentScopeKeys?: readonly string[];
|
||||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
|
onGithubPrDetected?: () => void;
|
||||||
|
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
/** Changing this value requests focus again while autoFocus remains true. */
|
||||||
|
autoFocusKey?: string;
|
||||||
/** Callback to expose a focus function to parent components (desktop only). */
|
/** Callback to expose a focus function to parent components (desktop only). */
|
||||||
onFocusInput?: (focus: () => void) => void;
|
onFocusInput?: (focus: () => void) => void;
|
||||||
/** Optional draft context for listing commands before an agent exists. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
@@ -972,6 +1039,7 @@ function ComposerVoiceModeButton({
|
|||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
onClientSlashCommand,
|
onClientSlashCommand,
|
||||||
@@ -981,6 +1049,7 @@ export function Composer({
|
|||||||
submitButtonTestID,
|
submitButtonTestID,
|
||||||
submitIcon = "arrow",
|
submitIcon = "arrow",
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
|
waitForGithubAutoAttachOnSubmit = false,
|
||||||
submitBehavior = "clear",
|
submitBehavior = "clear",
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -989,9 +1058,12 @@ export function Composer({
|
|||||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||||
onOpenWorkspaceAttachment,
|
onOpenWorkspaceAttachment,
|
||||||
onChangeAttachments,
|
onChangeAttachments,
|
||||||
|
onGithubPrDetected,
|
||||||
|
onGithubPrAutoAttach,
|
||||||
cwd,
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
autoFocusKey,
|
||||||
onFocusInput,
|
onFocusInput,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
@@ -1070,6 +1142,8 @@ export function Composer({
|
|||||||
cwd,
|
cwd,
|
||||||
supportsForgeSearch,
|
supportsForgeSearch,
|
||||||
setAttachments: setSelectedAttachments,
|
setAttachments: setSelectedAttachments,
|
||||||
|
onPullRequestDetected: onGithubPrDetected,
|
||||||
|
onPullRequestAdded: onGithubPrAutoAttach,
|
||||||
});
|
});
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -1185,6 +1259,21 @@ export function Composer({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceFileDropped = useCallback(
|
||||||
|
(payload: WorkspaceFileDragPayload) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
|
||||||
|
focusInput();
|
||||||
|
},
|
||||||
|
[focusInput, serverId, setSelectedAttachments, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFocusInput?.(focusInput);
|
onFocusInput?.(focusInput);
|
||||||
}, [focusInput, onFocusInput]);
|
}, [focusInput, onFocusInput]);
|
||||||
@@ -1907,7 +1996,7 @@ export function Composer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const composerContainerStyle = useMemo(
|
const composerContainerStyle = useMemo(
|
||||||
() => [styles.container, keyboardAnimatedStyle],
|
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
|
||||||
[keyboardAnimatedStyle],
|
[keyboardAnimatedStyle],
|
||||||
);
|
);
|
||||||
const inputAreaContainerStyle = useMemo(
|
const inputAreaContainerStyle = useMemo(
|
||||||
@@ -1949,13 +2038,21 @@ export function Composer({
|
|||||||
|
|
||||||
const messageInputContainerRef = useRef<View>(null);
|
const messageInputContainerRef = useRef<View>(null);
|
||||||
|
|
||||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
const isSubmitBusy =
|
||||||
|
isProcessing ||
|
||||||
|
isSubmitLoading ||
|
||||||
|
isUploadingFile ||
|
||||||
|
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||||
|
|
||||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||||
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
||||||
useFileDrop(
|
useFileDrop(
|
||||||
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
|
{
|
||||||
|
onFiles: addImages,
|
||||||
|
onGenericFiles: handleGenericFilesDropped,
|
||||||
|
onWorkspaceFile: handleWorkspaceFileDropped,
|
||||||
|
},
|
||||||
{ disabled: isSubmitBusy },
|
{ disabled: isSubmitBusy },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2016,7 +2113,7 @@ export function Composer({
|
|||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
placeholder={messagePlaceholder}
|
placeholder={messagePlaceholder}
|
||||||
autoFocus={messageInputAutoFocus}
|
autoFocus={messageInputAutoFocus}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
@@ -2064,11 +2161,14 @@ export function Composer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme: Theme) => ({
|
const animatedStaticStyles = RNStyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: Theme) => ({
|
||||||
borderSeparator: {
|
borderSeparator: {
|
||||||
height: theme.borderWidth[1],
|
height: theme.borderWidth[1],
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
|
runMessageInputKeyboardAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
@@ -496,65 +497,6 @@ function handleDesktopKeyPressImpl(
|
|||||||
ctx.handleDefaultSendAction();
|
ctx.handleDefaultSendAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardActionHandlers {
|
|
||||||
textInputRef: React.MutableRefObject<
|
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
|
||||||
>;
|
|
||||||
isDictatingRef: React.MutableRefObject<boolean>;
|
|
||||||
sendAfterTranscriptRef: React.MutableRefObject<boolean>;
|
|
||||||
confirmDictation: () => void | Promise<void>;
|
|
||||||
cancelDictation: () => void | Promise<void>;
|
|
||||||
startDictationIfAvailable: () => Promise<void>;
|
|
||||||
handleToggleRealtimeVoiceShortcut: () => void;
|
|
||||||
isRealtimeVoiceForCurrentAgent: boolean;
|
|
||||||
voice: { toggleMute: () => void } | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runKeyboardActionImpl(
|
|
||||||
action: MessageInputKeyboardActionKind,
|
|
||||||
h: KeyboardActionHandlers,
|
|
||||||
): boolean {
|
|
||||||
if (action === "focus") {
|
|
||||||
h.textInputRef.current?.focus();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (action === "send" || action === "dictation-confirm") {
|
|
||||||
if (h.isDictatingRef.current) {
|
|
||||||
h.sendAfterTranscriptRef.current = true;
|
|
||||||
void h.confirmDictation();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (action === "voice-toggle") {
|
|
||||||
h.handleToggleRealtimeVoiceShortcut();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (action === "voice-mute-toggle") {
|
|
||||||
if (h.isRealtimeVoiceForCurrentAgent) {
|
|
||||||
h.voice?.toggleMute();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (action === "dictation-cancel") {
|
|
||||||
if (h.isDictatingRef.current) {
|
|
||||||
void h.cancelDictation();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (action === "dictation-toggle") {
|
|
||||||
if (h.isDictatingRef.current) {
|
|
||||||
h.sendAfterTranscriptRef.current = true;
|
|
||||||
void h.confirmDictation();
|
|
||||||
} else {
|
|
||||||
void h.startDictationIfAvailable();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTextInputNativeElement(
|
function getTextInputNativeElement(
|
||||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||||
): HTMLElement | null {
|
): HTMLElement | null {
|
||||||
@@ -916,22 +858,18 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
|||||||
interface StartDictationContext {
|
interface StartDictationContext {
|
||||||
dictationUnavailableMessage: string | null | undefined;
|
dictationUnavailableMessage: string | null | undefined;
|
||||||
canStartDictation: () => boolean;
|
canStartDictation: () => boolean;
|
||||||
isDictatingRef: React.MutableRefObject<boolean>;
|
|
||||||
toast: { error: (msg: string) => void };
|
toast: { error: (msg: string) => void };
|
||||||
startDictation: () => Promise<void>;
|
startDictation: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
||||||
if (ctx.dictationUnavailableMessage) {
|
if (ctx.dictationUnavailableMessage) {
|
||||||
ctx.isDictatingRef.current = false;
|
|
||||||
ctx.toast.error(ctx.dictationUnavailableMessage);
|
ctx.toast.error(ctx.dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ctx.canStartDictation()) {
|
if (!ctx.canStartDictation()) {
|
||||||
ctx.isDictatingRef.current = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.isDictatingRef.current = true;
|
|
||||||
await ctx.startDictation();
|
await ctx.startDictation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,16 +1214,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
textInputRef.current?.blur?.();
|
textInputRef.current?.blur?.();
|
||||||
},
|
},
|
||||||
runKeyboardAction: (action) =>
|
runKeyboardAction: (action) =>
|
||||||
runKeyboardActionImpl(action, {
|
runMessageInputKeyboardAction(action, {
|
||||||
textInputRef,
|
focusInput: () => textInputRef.current?.focus(),
|
||||||
isDictatingRef,
|
isDictationRecording: isDictationActive,
|
||||||
sendAfterTranscriptRef,
|
markTranscriptForSend: () => {
|
||||||
|
sendAfterTranscriptRef.current = true;
|
||||||
|
},
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
startDictationIfAvailable,
|
startDictation: startDictationIfAvailable,
|
||||||
handleToggleRealtimeVoiceShortcut,
|
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
|
||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
|
||||||
voice,
|
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
|
||||||
}),
|
}),
|
||||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||||
}));
|
}));
|
||||||
@@ -1369,6 +1309,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isRecording: isDictating,
|
isRecording: isDictating,
|
||||||
|
isRecordingActive: isDictationActive,
|
||||||
isProcessing: isDictationProcessing,
|
isProcessing: isDictationProcessing,
|
||||||
partialTranscript: _dictationPartialTranscript,
|
partialTranscript: _dictationPartialTranscript,
|
||||||
volume: dictationVolume,
|
volume: dictationVolume,
|
||||||
@@ -1389,11 +1330,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDictatingRef = useRef(isDictating);
|
|
||||||
useEffect(() => {
|
|
||||||
isDictatingRef.current = isDictating;
|
|
||||||
}, [isDictating]);
|
|
||||||
|
|
||||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
||||||
voice,
|
voice,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
@@ -1420,7 +1356,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
startDictationIfAvailableImpl({
|
startDictationIfAvailableImpl({
|
||||||
dictationUnavailableMessage,
|
dictationUnavailableMessage,
|
||||||
canStartDictation,
|
canStartDictation,
|
||||||
isDictatingRef,
|
|
||||||
toast,
|
toast,
|
||||||
startDictation,
|
startDictation,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,12 +4,40 @@ import {
|
|||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
|
runMessageInputKeyboardAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
const connected = { isConnected: true } as never;
|
const connected = { isConnected: true } as never;
|
||||||
const disconnected = { isConnected: false } as never;
|
const disconnected = { isConnected: false } as never;
|
||||||
|
|
||||||
|
function createDictationKeyboard({ startsRecording }: { startsRecording: boolean }) {
|
||||||
|
let isRecording = false;
|
||||||
|
const actions: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
actions,
|
||||||
|
pressDictationShortcut: () =>
|
||||||
|
runMessageInputKeyboardAction("dictation-toggle", {
|
||||||
|
focusInput: () => undefined,
|
||||||
|
isDictationRecording: () => isRecording,
|
||||||
|
markTranscriptForSend: () => actions.push("send transcript"),
|
||||||
|
startDictation: () => {
|
||||||
|
actions.push("start");
|
||||||
|
isRecording = startsRecording;
|
||||||
|
},
|
||||||
|
confirmDictation: () => {
|
||||||
|
actions.push("confirm");
|
||||||
|
isRecording = false;
|
||||||
|
},
|
||||||
|
cancelDictation: () => undefined,
|
||||||
|
toggleRealtimeVoice: () => undefined,
|
||||||
|
isRealtimeVoiceActive: false,
|
||||||
|
toggleRealtimeVoiceMute: () => undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("composer surface presentation", () => {
|
describe("composer surface presentation", () => {
|
||||||
it("shows only the input when no voice overlay is active", () => {
|
it("shows only the input when no voice overlay is active", () => {
|
||||||
expect(resolveComposerSurfacePresentation(false)).toEqual({
|
expect(resolveComposerSurfacePresentation(false)).toEqual({
|
||||||
@@ -114,6 +142,27 @@ describe("computeCanStartDictation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dictation keyboard behavior", () => {
|
||||||
|
it("starts dictation again after the previous dictation finishes", () => {
|
||||||
|
const keyboard = createDictationKeyboard({ startsRecording: true });
|
||||||
|
|
||||||
|
keyboard.pressDictationShortcut();
|
||||||
|
keyboard.pressDictationShortcut();
|
||||||
|
keyboard.pressDictationShortcut();
|
||||||
|
|
||||||
|
expect(keyboard.actions).toEqual(["start", "send transcript", "confirm", "start"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can retry when starting dictation does not enter the recording state", () => {
|
||||||
|
const keyboard = createDictationKeyboard({ startsRecording: false });
|
||||||
|
|
||||||
|
keyboard.pressDictationShortcut();
|
||||||
|
keyboard.pressDictationShortcut();
|
||||||
|
|
||||||
|
expect(keyboard.actions).toEqual(["start", "start"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("composer send behavior", () => {
|
describe("composer send behavior", () => {
|
||||||
function actions() {
|
function actions() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { MessagePayload } from "@/composer/types";
|
import type { MessagePayload } from "@/composer/types";
|
||||||
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
|
||||||
export type SendBehavior = "interrupt" | "queue";
|
export type SendBehavior = "interrupt" | "queue";
|
||||||
|
|
||||||
@@ -45,6 +46,18 @@ interface SendActionContext {
|
|||||||
handleQueueMessage: () => void;
|
handleQueueMessage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageInputKeyboardActions {
|
||||||
|
focusInput: () => void;
|
||||||
|
isDictationRecording: () => boolean;
|
||||||
|
markTranscriptForSend: () => void;
|
||||||
|
confirmDictation: () => void | Promise<void>;
|
||||||
|
cancelDictation: () => void | Promise<void>;
|
||||||
|
startDictation: () => void | Promise<void>;
|
||||||
|
toggleRealtimeVoice: () => void;
|
||||||
|
isRealtimeVoiceActive: boolean;
|
||||||
|
toggleRealtimeVoiceMute: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeCanStartDictation(input: {
|
export function computeCanStartDictation(input: {
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
isReadyForDictation: boolean | undefined;
|
isReadyForDictation: boolean | undefined;
|
||||||
@@ -76,6 +89,51 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runMessageInputKeyboardAction(
|
||||||
|
action: MessageInputKeyboardActionKind,
|
||||||
|
actions: MessageInputKeyboardActions,
|
||||||
|
): boolean {
|
||||||
|
if (action === "focus") {
|
||||||
|
actions.focusInput();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action === "send" || action === "dictation-confirm") {
|
||||||
|
if (actions.isDictationRecording()) {
|
||||||
|
actions.markTranscriptForSend();
|
||||||
|
void actions.confirmDictation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (action === "voice-toggle") {
|
||||||
|
actions.toggleRealtimeVoice();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action === "voice-mute-toggle") {
|
||||||
|
if (actions.isRealtimeVoiceActive) {
|
||||||
|
actions.toggleRealtimeVoiceMute();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action === "dictation-cancel") {
|
||||||
|
if (actions.isDictationRecording()) {
|
||||||
|
void actions.cancelDictation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (action === "dictation-toggle") {
|
||||||
|
if (actions.isDictationRecording()) {
|
||||||
|
actions.markTranscriptForSend();
|
||||||
|
void actions.confirmDictation();
|
||||||
|
} else {
|
||||||
|
void actions.startDictation();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
|
|||||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
buildAgentAttentionNotificationPayload,
|
buildAgentAttentionNotificationPayload,
|
||||||
|
type AgentAttentionReason,
|
||||||
type AgentAttentionNotificationPayload,
|
type AgentAttentionNotificationPayload,
|
||||||
type NotificationPermissionRequest,
|
type NotificationPermissionRequest,
|
||||||
} from "@getpaseo/protocol/agent-attention-notification";
|
} from "@getpaseo/protocol/agent-attention-notification";
|
||||||
@@ -154,6 +155,35 @@ const getLatestPermissionRequest = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AgentAttentionNotificationInput {
|
||||||
|
notification?: AgentAttentionNotificationPayload;
|
||||||
|
reason: AgentAttentionReason;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string | undefined;
|
||||||
|
agentId: string;
|
||||||
|
assistantMessage: string | null;
|
||||||
|
permissionRequest: NotificationPermissionRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentAttentionNotification(
|
||||||
|
input: AgentAttentionNotificationInput,
|
||||||
|
): AgentAttentionNotificationPayload | null {
|
||||||
|
if (input.notification) {
|
||||||
|
return input.notification.data.workspaceId ? input.notification : null;
|
||||||
|
}
|
||||||
|
if (!input.workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildAgentAttentionNotificationPayload({
|
||||||
|
reason: input.reason,
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
|
||||||
|
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceSetupProgressPayload = Extract<
|
type WorkspaceSetupProgressPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_setup_progress" }
|
{ type: "workspace_setup_progress" }
|
||||||
@@ -387,8 +417,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Zustand store actions
|
// Zustand store actions
|
||||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
|
||||||
const clearSession = useSessionStore((state) => state.clearSession);
|
|
||||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||||
const setMessages = useSessionStore((state) => state.setMessages);
|
const setMessages = useSessionStore((state) => state.setMessages);
|
||||||
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
|
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
|
||||||
@@ -410,11 +438,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
|
||||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
|
||||||
|
|
||||||
// Track focused agent for heartbeat
|
// Track focused agent for heartbeat
|
||||||
const focusedAgentId = useSessionStore(
|
const focusedAgentId = useSessionStore(
|
||||||
@@ -504,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const assistantMessage =
|
const assistantMessage =
|
||||||
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
||||||
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
||||||
|
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
|
||||||
|
|
||||||
const notification =
|
const notification = resolveAgentAttentionNotification({
|
||||||
params.notification ??
|
notification: params.notification,
|
||||||
buildAgentAttentionNotificationPayload({
|
reason: params.reason,
|
||||||
reason: params.reason,
|
serverId,
|
||||||
serverId,
|
workspaceId,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
assistantMessage,
|
||||||
permissionRequest: params.reason === "permission" ? permissionRequest : null,
|
permissionRequest,
|
||||||
});
|
});
|
||||||
|
if (!notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void sendOsNotification({
|
void sendOsNotification({
|
||||||
title: notification.title,
|
title: notification.title,
|
||||||
@@ -524,17 +554,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
[serverId],
|
[serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize session in store
|
|
||||||
useEffect(() => {
|
|
||||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
|
||||||
initializeSession(serverId, client, generation);
|
|
||||||
}, [serverId, client, initializeSession]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
|
||||||
updateSessionClient(serverId, client, generation);
|
|
||||||
}, [serverId, client, updateSessionClient]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serverInfo = client.getLastServerInfoMessage();
|
const serverInfo = client.getLastServerInfoMessage();
|
||||||
if (!serverInfo) {
|
if (!serverInfo) {
|
||||||
@@ -1327,13 +1346,5 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
[client],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearWorkspaceSetupServer(serverId);
|
|
||||||
clearSession(serverId);
|
|
||||||
};
|
|
||||||
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Auggie CLI",
|
title: "Auggie CLI",
|
||||||
description:
|
description:
|
||||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||||
version: "0.32.0",
|
version: "0.33.0",
|
||||||
iconId: "auggie",
|
iconId: "auggie",
|
||||||
installLink: "https://www.augmentcode.com/",
|
installLink: "https://www.augmentcode.com/",
|
||||||
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
|
||||||
env: {
|
env: {
|
||||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||||
},
|
},
|
||||||
@@ -59,10 +59,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.44",
|
version: "3.0.46",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.44", "--acp"],
|
command: ["npx", "-y", "cline@3.0.46", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
|
|||||||
id: "deepagents",
|
id: "deepagents",
|
||||||
title: "DeepAgents",
|
title: "DeepAgents",
|
||||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||||
version: "0.1.19",
|
version: "0.1.20",
|
||||||
iconId: "deepagents",
|
iconId: "deepagents",
|
||||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||||
command: ["npx", "-y", "deepagents-acp@0.1.19"],
|
command: ["npx", "-y", "deepagents-acp@0.1.20"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "devin",
|
id: "devin",
|
||||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.31",
|
version: "0.2.35",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.31", "acp"],
|
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
description:
|
||||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||||
version: "0.4.17",
|
version: "0.4.22",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.174.0",
|
version: "0.177.0",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.14",
|
version: "0.9.21",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
@@ -192,10 +192,10 @@ const CATALOG_DATA = [
|
|||||||
title: "GLM Agent",
|
title: "GLM Agent",
|
||||||
description:
|
description:
|
||||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||||
version: "1.1.4",
|
version: "1.3.0",
|
||||||
iconId: "glm-acp-agent",
|
iconId: "glm-acp-agent",
|
||||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||||
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
|
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "goose",
|
id: "goose",
|
||||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
|||||||
id: "nova",
|
id: "nova",
|
||||||
title: "Nova",
|
title: "Nova",
|
||||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||||
version: "1.1.27",
|
version: "1.1.29",
|
||||||
iconId: "nova",
|
iconId: "nova",
|
||||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||||
command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"],
|
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "poolside",
|
id: "poolside",
|
||||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.0.48",
|
version: "1.1.3",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.19.11",
|
version: "0.20.1",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
|
interface OpenAgentEventPayload {
|
||||||
|
serverId?: unknown;
|
||||||
|
agentId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentNavigationListener() {
|
||||||
|
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
|
||||||
|
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
|
||||||
|
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToAgent({ serverId, agentId });
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = getDesktopHost();
|
||||||
|
const ready = host?.agentNavigation?.ready;
|
||||||
|
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
let dispose: (() => void) | null = null;
|
||||||
|
try {
|
||||||
|
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
|
||||||
|
if (disposed) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = dispose;
|
||||||
|
const pending = await ready();
|
||||||
|
if (!disposed && pending) {
|
||||||
|
openAgent(pending);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dispose?.();
|
||||||
|
if (unlisten === dispose) {
|
||||||
|
unlisten = null;
|
||||||
|
}
|
||||||
|
if (!disposed) {
|
||||||
|
retryTimer = setTimeout(() => void connect(), 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [openAgent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -123,6 +123,10 @@ export interface DesktopEventsBridge {
|
|||||||
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopAgentNavigationBridge {
|
||||||
|
ready?: () => Promise<{ serverId: string; agentId: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export type DesktopBrowserShortcutEvent =
|
export type DesktopBrowserShortcutEvent =
|
||||||
| { browserId?: string; action: "focus-url" }
|
| { browserId?: string; action: "focus-url" }
|
||||||
| { browserId: string; action: "new-tab" };
|
| { browserId: string; action: "new-tab" };
|
||||||
@@ -169,6 +173,7 @@ export interface DesktopHostBridge {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
invoke?: DesktopInvokeBridge["invoke"];
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
getPendingOpenProject?: () => Promise<string | null>;
|
getPendingOpenProject?: () => Promise<string | null>;
|
||||||
|
agentNavigation?: DesktopAgentNavigationBridge;
|
||||||
events?: DesktopEventsBridge;
|
events?: DesktopEventsBridge;
|
||||||
window?: DesktopWindowModuleBridge;
|
window?: DesktopWindowModuleBridge;
|
||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { FileConflictAlert } from "./conflict-alert";
|
||||||
|
import type { FileEditorStatus } from "./editor/model";
|
||||||
|
|
||||||
|
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
export function FilePanelBar({
|
||||||
|
size,
|
||||||
|
lineCount,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
editorStatus,
|
||||||
|
cursor,
|
||||||
|
vimMode,
|
||||||
|
conflictUnavailable,
|
||||||
|
onOverwrite,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
size: number;
|
||||||
|
lineCount?: number;
|
||||||
|
mode?: "preview" | "source";
|
||||||
|
onModeChange?(mode: "preview" | "source"): void;
|
||||||
|
editorStatus?: FileEditorStatus;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
vimMode?: string | null;
|
||||||
|
conflictUnavailable?: boolean;
|
||||||
|
onOverwrite?(): void;
|
||||||
|
onReload?(): void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const markdownModes = [
|
||||||
|
{
|
||||||
|
value: "preview" as const,
|
||||||
|
label: t("panels.file.editor.preview"),
|
||||||
|
testID: "file-mode-preview",
|
||||||
|
},
|
||||||
|
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<View style={styles.chrome} testID="file-panel-bar">
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.metadata}>
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||||
|
>
|
||||||
|
{formatFileSize(size)}
|
||||||
|
</Text>
|
||||||
|
{lineCount !== undefined ? (
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||||
|
>
|
||||||
|
{t("panels.file.editor.lines", { count: lineCount })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={styles.status}
|
||||||
|
accessibilityLabel={
|
||||||
|
editorStatus
|
||||||
|
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editorStatus === "dirty" ? (
|
||||||
|
<View
|
||||||
|
style={styles.dirtyDot}
|
||||||
|
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||||
|
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "error" ? (
|
||||||
|
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "conflict" ? (
|
||||||
|
<Text style={styles.error}>{t("panels.file.editor.changedOnDisk")}</Text>
|
||||||
|
) : null}
|
||||||
|
{vimMode ? (
|
||||||
|
<Text
|
||||||
|
style={styles.vim}
|
||||||
|
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||||
|
>
|
||||||
|
{vimMode}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{cursor ? (
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||||
|
>
|
||||||
|
Ln {cursor.line}, Col {cursor.column}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{mode && onModeChange ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={onModeChange}
|
||||||
|
testID="file-markdown-mode"
|
||||||
|
options={markdownModes}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{editorStatus === "conflict" && onOverwrite && onReload ? (
|
||||||
|
<View style={styles.notice}>
|
||||||
|
<FileConflictAlert
|
||||||
|
unavailable={conflictUnavailable ?? false}
|
||||||
|
onOverwrite={onOverwrite}
|
||||||
|
onReload={onReload}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
chrome: {
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
minHeight: 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs },
|
||||||
|
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.xs },
|
||||||
|
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs },
|
||||||
|
dirtyDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
flexShrink: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
vim: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] },
|
||||||
|
}));
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function FileConflictAlert({
|
||||||
|
unavailable,
|
||||||
|
onOverwrite,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
unavailable: boolean;
|
||||||
|
onOverwrite(): void;
|
||||||
|
onReload(): void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
title={
|
||||||
|
unavailable
|
||||||
|
? t("panels.file.editor.unavailableTitle")
|
||||||
|
: t("panels.file.editor.changedOnDisk")
|
||||||
|
}
|
||||||
|
description={t("panels.file.editor.conflictDescription")}
|
||||||
|
testID="file-conflict-alert"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onPress={onOverwrite} disabled={unavailable}>
|
||||||
|
{t("panels.file.editor.overwrite")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onPress={onReload} disabled={unavailable}>
|
||||||
|
{t("panels.file.editor.reload")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
|
import {
|
||||||
|
bracketMatching,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { searchKeymap } from "@codemirror/search";
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
drawSelection,
|
||||||
|
highlightActiveLine,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
|
||||||
|
|
||||||
|
export interface EditorVisualTheme {
|
||||||
|
colorScheme: "light" | "dark";
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
cursor: string;
|
||||||
|
foregroundMuted: string;
|
||||||
|
border: string;
|
||||||
|
selection: string;
|
||||||
|
monoFont: string;
|
||||||
|
codeFontSize: number;
|
||||||
|
syntax: Record<HighlightStyle, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorBaseExtensions(onSave: () => void) {
|
||||||
|
return [
|
||||||
|
lineNumbers(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
indentOnInput(),
|
||||||
|
bracketMatching(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
keymap.of([
|
||||||
|
{ key: "Mod-s", preventDefault: true, run: () => (onSave(), true) },
|
||||||
|
indentWithTab,
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorTheme(theme: EditorVisualTheme) {
|
||||||
|
return [
|
||||||
|
EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
color: theme.foreground,
|
||||||
|
fontFamily: theme.monoFont,
|
||||||
|
fontSize: `${theme.codeFontSize}px`,
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: theme.monoFont,
|
||||||
|
lineHeight: "1.45",
|
||||||
|
},
|
||||||
|
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
color: theme.foregroundMuted,
|
||||||
|
borderRight: `1px solid ${theme.border}`,
|
||||||
|
},
|
||||||
|
".cm-activeLine": { backgroundColor: "transparent" },
|
||||||
|
".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground },
|
||||||
|
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||||
|
backgroundColor: theme.selection,
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, ::selection": {
|
||||||
|
backgroundColor: theme.selection,
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
},
|
||||||
|
{ dark: theme.colorScheme === "dark" },
|
||||||
|
),
|
||||||
|
syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||||
|
import {
|
||||||
|
FileEditorModel,
|
||||||
|
type FileEditorClock,
|
||||||
|
type FileEditorFile,
|
||||||
|
type FileEditorSession,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
class TestClock implements FileEditorClock {
|
||||||
|
private callback: (() => void) | null = null;
|
||||||
|
|
||||||
|
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
||||||
|
this.callback = callback;
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(): void {
|
||||||
|
this.callback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fire(): void {
|
||||||
|
const callback = this.callback;
|
||||||
|
this.callback = null;
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileSession implements FileEditorSession {
|
||||||
|
file: FileEditorFile;
|
||||||
|
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
|
||||||
|
nextWrite: FileWriteResult | Error | null = null;
|
||||||
|
private pendingWrite: Promise<FileWriteResult> | null = null;
|
||||||
|
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(file: FileEditorFile) {
|
||||||
|
this.file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(): Promise<FileEditorFile> {
|
||||||
|
return this.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(input: {
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}): Promise<FileWriteResult> {
|
||||||
|
this.writes.push(input);
|
||||||
|
if (this.pendingWrite) return this.pendingWrite;
|
||||||
|
if (this.nextWrite instanceof Error) throw this.nextWrite;
|
||||||
|
if (this.nextWrite) return this.nextWrite;
|
||||||
|
return {
|
||||||
|
status: "written",
|
||||||
|
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||||
|
size: input.content.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
holdNextWrite(): void {
|
||||||
|
this.pendingWrite = new Promise((resolve) => {
|
||||||
|
this.resolvePendingWrite = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finishHeldWrite(result: FileWriteResult): void {
|
||||||
|
this.resolvePendingWrite?.(result);
|
||||||
|
this.pendingWrite = null;
|
||||||
|
this.resolvePendingWrite = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(
|
||||||
|
modifiedAt = "2026-07-18T00:00:00.000Z",
|
||||||
|
size = 3,
|
||||||
|
): Extract<FileVersion, { status: "ready" }> {
|
||||||
|
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeModel() {
|
||||||
|
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
|
||||||
|
const session = new FileSession(file);
|
||||||
|
const clock = new TestClock();
|
||||||
|
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FileEditorModel", () => {
|
||||||
|
test("tracks whether the current buffer differs from persisted content", async () => {
|
||||||
|
const { model } = makeModel();
|
||||||
|
|
||||||
|
expect(model.getSnapshot().modified).toBe(false);
|
||||||
|
model.edit("two");
|
||||||
|
expect(model.getSnapshot().modified).toBe(true);
|
||||||
|
model.edit("one");
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||||
|
|
||||||
|
model.edit("saved");
|
||||||
|
await model.save();
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
||||||
|
const { model } = makeModel();
|
||||||
|
|
||||||
|
model.receiveFileVersion({ ...ready(), revision: "precise-revision" });
|
||||||
|
|
||||||
|
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.holdNextWrite();
|
||||||
|
model.edit("saving");
|
||||||
|
|
||||||
|
const save = model.save();
|
||||||
|
model.edit("newer edit");
|
||||||
|
session.finishHeldWrite({
|
||||||
|
status: "written",
|
||||||
|
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||||
|
size: 6,
|
||||||
|
});
|
||||||
|
await save;
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
|
status: "dirty",
|
||||||
|
content: "newer edit",
|
||||||
|
modified: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autosaves the latest edit after inactivity", async () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
|
||||||
|
model.edit("two");
|
||||||
|
model.edit("three");
|
||||||
|
clock.fire();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([
|
||||||
|
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
expect(model.getSnapshot().status).toBe("clean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reloads a clean editor when the disk version changes", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.file = {
|
||||||
|
content: "external",
|
||||||
|
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
model.receiveFileVersion(session.file.version);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
const reads: Array<(file: FileEditorFile) => void> = [];
|
||||||
|
session.read = () => new Promise((resolve) => reads.push(resolve));
|
||||||
|
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
||||||
|
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
||||||
|
|
||||||
|
model.receiveFileVersion(firstVersion);
|
||||||
|
model.receiveFileVersion(latestVersion);
|
||||||
|
reads[0]?.({ content: "first", version: firstVersion });
|
||||||
|
await Promise.resolve();
|
||||||
|
reads[1]?.({ content: "latest", version: latestVersion });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
|
||||||
|
await model.overwrite();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([
|
||||||
|
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||||
|
]);
|
||||||
|
expect(model.getSnapshot().status).toBe("clean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reload discards a conflicted local buffer for the disk contents", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
|
||||||
|
FileVersion,
|
||||||
|
{ status: "ready" }
|
||||||
|
>;
|
||||||
|
session.file = { content: "disk", version: diskVersion };
|
||||||
|
model.receiveFileVersion(diskVersion);
|
||||||
|
|
||||||
|
await model.reload();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports failed saves without losing the local buffer", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.nextWrite = new Error("disk full");
|
||||||
|
model.edit("important local work");
|
||||||
|
|
||||||
|
await model.save();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
content: "important local work",
|
||||||
|
error: "disk full",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a deletion conflicts with local changes and stops autosave", () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||||
|
|
||||||
|
clock.fire();
|
||||||
|
|
||||||
|
expect(model.getSnapshot().status).toBe("conflict");
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose cancels pending autosave", () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
|
||||||
|
model.dispose();
|
||||||
|
clock.fire();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suspends a pending autosave while close confirmation is active", async () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
|
||||||
|
const resume = model.suspendAutosave();
|
||||||
|
clock.fire();
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
|
||||||
|
resume();
|
||||||
|
clock.fire();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(session.writes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||||
|
|
||||||
|
export interface FileEditorSnapshot {
|
||||||
|
status: FileEditorStatus;
|
||||||
|
content: string;
|
||||||
|
modified: boolean;
|
||||||
|
version: FileVersion;
|
||||||
|
observedVersion: FileVersion;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorFile {
|
||||||
|
content: string;
|
||||||
|
version: Extract<FileVersion, { status: "ready" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorSession {
|
||||||
|
read(): Promise<FileEditorFile>;
|
||||||
|
write(input: {
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}): Promise<FileWriteResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorClock {
|
||||||
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemClock: FileEditorClock = {
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
return globalThis.setTimeout(callback, delay);
|
||||||
|
},
|
||||||
|
clearTimeout(handle) {
|
||||||
|
globalThis.clearTimeout(handle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FileEditorModel {
|
||||||
|
private readonly session: FileEditorSession;
|
||||||
|
private readonly clock: FileEditorClock;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private snapshot: FileEditorSnapshot;
|
||||||
|
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private saveSequence = 0;
|
||||||
|
private disposed = false;
|
||||||
|
private observedWhileSaving: FileVersion | null = null;
|
||||||
|
private persistedContent: string;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
file: FileEditorFile;
|
||||||
|
session: FileEditorSession;
|
||||||
|
clock?: FileEditorClock;
|
||||||
|
}) {
|
||||||
|
this.session = input.session;
|
||||||
|
this.clock = input.clock ?? systemClock;
|
||||||
|
this.persistedContent = input.file.content;
|
||||||
|
this.snapshot = {
|
||||||
|
status: "clean",
|
||||||
|
content: input.file.content,
|
||||||
|
modified: false,
|
||||||
|
version: input.file.version,
|
||||||
|
observedVersion: input.file.version,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (listener: () => void): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
edit(content: string): void {
|
||||||
|
if (this.disposed || content === this.snapshot.content) return;
|
||||||
|
const modified = content !== this.persistedContent;
|
||||||
|
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||||
|
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
|
||||||
|
status = "conflict";
|
||||||
|
}
|
||||||
|
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||||
|
if (status === "dirty") this.scheduleAutosave();
|
||||||
|
else this.clearAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.snapshot.observedVersion.status !== "ready") {
|
||||||
|
this.enterConflict(this.snapshot.observedVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.performWrite(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveFileVersion(version: FileVersion): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (sameVersion(version, this.snapshot.observedVersion)) {
|
||||||
|
if (
|
||||||
|
version.status === "ready" &&
|
||||||
|
this.snapshot.observedVersion.status === "ready" &&
|
||||||
|
version.revision &&
|
||||||
|
!this.snapshot.observedVersion.revision
|
||||||
|
) {
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
version:
|
||||||
|
this.snapshot.version.status === "ready"
|
||||||
|
? { ...this.snapshot.version, revision: version.revision }
|
||||||
|
: this.snapshot.version,
|
||||||
|
observedVersion: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||||
|
if (this.snapshot.status === "saving") {
|
||||||
|
this.observedWhileSaving = version;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
|
||||||
|
void this.reloadFromDisk(version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.enterConflict(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async overwrite(): Promise<void> {
|
||||||
|
if (this.disposed || this.snapshot.status !== "conflict") return;
|
||||||
|
if (this.snapshot.observedVersion.status !== "ready") return;
|
||||||
|
await this.performWrite(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
if (this.disposed) return;
|
||||||
|
await this.reloadFromDisk(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
this.saveSequence += 1;
|
||||||
|
this.clearAutosave();
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendAutosave(): () => void {
|
||||||
|
const wasScheduled = this.autosave !== null;
|
||||||
|
this.clearAutosave();
|
||||||
|
let resumed = false;
|
||||||
|
return () => {
|
||||||
|
if (resumed || this.disposed) return;
|
||||||
|
resumed = true;
|
||||||
|
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performWrite(
|
||||||
|
expectedVersion: Extract<FileVersion, { status: "ready" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
this.clearAutosave();
|
||||||
|
const sequence = ++this.saveSequence;
|
||||||
|
const content = this.snapshot.content;
|
||||||
|
this.observedWhileSaving = null;
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
|
||||||
|
let result: FileWriteResult;
|
||||||
|
try {
|
||||||
|
result = await this.session.write({
|
||||||
|
content,
|
||||||
|
expectedModifiedAt: expectedVersion.modifiedAt,
|
||||||
|
expectedRevision: expectedVersion.revision,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
if (result.status === "error") {
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === "conflict") {
|
||||||
|
this.enterConflict(result.version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenVersion: FileVersion = {
|
||||||
|
status: "ready",
|
||||||
|
cwd: this.snapshot.version.cwd,
|
||||||
|
path: this.snapshot.version.path,
|
||||||
|
size: result.size,
|
||||||
|
modifiedAt: result.modifiedAt,
|
||||||
|
revision: result.revision,
|
||||||
|
};
|
||||||
|
const pending = this.observedWhileSaving;
|
||||||
|
this.observedWhileSaving = null;
|
||||||
|
this.persistedContent = content;
|
||||||
|
if (pending && !sameVersion(pending, writtenVersion)) {
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "conflict",
|
||||||
|
modified: this.snapshot.content !== this.persistedContent,
|
||||||
|
version: writtenVersion,
|
||||||
|
observedVersion: pending,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modified = this.snapshot.content !== this.persistedContent;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: modified ? "dirty" : "clean",
|
||||||
|
modified,
|
||||||
|
version: writtenVersion,
|
||||||
|
observedVersion: writtenVersion,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (modified) this.scheduleAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reloadFromDisk(version: FileVersion): Promise<void> {
|
||||||
|
this.clearAutosave();
|
||||||
|
if (version.status !== "ready") {
|
||||||
|
this.enterConflict(version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = ++this.saveSequence;
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
|
||||||
|
try {
|
||||||
|
const file = await this.session.read();
|
||||||
|
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.persistedContent = file.content;
|
||||||
|
this.setSnapshot({
|
||||||
|
status: "clean",
|
||||||
|
content: file.content,
|
||||||
|
modified: false,
|
||||||
|
version: file.version,
|
||||||
|
observedVersion: file.version,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enterConflict(version: FileVersion): void {
|
||||||
|
this.clearAutosave();
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "conflict",
|
||||||
|
modified: this.snapshot.content !== this.persistedContent,
|
||||||
|
observedVersion: version,
|
||||||
|
error: version.status === "error" ? version.error : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleAutosave(): void {
|
||||||
|
this.clearAutosave();
|
||||||
|
this.autosave = this.clock.setTimeout(() => {
|
||||||
|
this.autosave = null;
|
||||||
|
void this.save();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAutosave(): void {
|
||||||
|
if (!this.autosave) return;
|
||||||
|
this.clock.clearTimeout(this.autosave);
|
||||||
|
this.autosave = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshot(snapshot: FileEditorSnapshot): void {
|
||||||
|
this.snapshot = snapshot;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
||||||
|
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
|
||||||
|
return false;
|
||||||
|
if (left.status === "ready" && right.status === "ready") {
|
||||||
|
if (left.revision && right.revision) return left.revision === right.revision;
|
||||||
|
return left.modifiedAt === right.modifiedAt && left.size === right.size;
|
||||||
|
}
|
||||||
|
if (left.status === "error" && right.status === "error") return left.error === right.error;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { FileEditorModel } from "./model";
|
||||||
|
|
||||||
|
export function FileEditorView(_props: {
|
||||||
|
model: FileEditorModel;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
vimEnabled: boolean;
|
||||||
|
theme: {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
foregroundMuted: string;
|
||||||
|
border: string;
|
||||||
|
selection: string;
|
||||||
|
monoFont: string;
|
||||||
|
codeFontSize: number;
|
||||||
|
syntax: Record<HighlightStyle, string>;
|
||||||
|
};
|
||||||
|
onCursorChange(position: { line: number; column: number }): void;
|
||||||
|
onVimModeChange(mode: string | null): void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||||
|
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||||
|
}));
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||||
|
import { getCM, vim } from "@replit/codemirror-vim";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { FileEditorModel } from "./model";
|
||||||
|
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||||
|
|
||||||
|
interface FileEditorViewProps {
|
||||||
|
model: FileEditorModel;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
vimEnabled: boolean;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
onCursorChange(position: { line: number; column: number }): void;
|
||||||
|
onVimModeChange(mode: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const vimCompartment = new Compartment();
|
||||||
|
|
||||||
|
export function FileEditorView({
|
||||||
|
model,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
vimEnabled,
|
||||||
|
theme,
|
||||||
|
onCursorChange,
|
||||||
|
onVimModeChange,
|
||||||
|
}: FileEditorViewProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
|
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||||
|
const onCursorChangeRef = useRef(onCursorChange);
|
||||||
|
onCursorChangeRef.current = onCursorChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const values = initial.current;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent: hostRef.current,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: values.content,
|
||||||
|
extensions: [
|
||||||
|
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||||
|
...editorBaseExtensions(() => void values.model.save()),
|
||||||
|
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||||
|
themeCompartment.of(editorTheme(values.theme)),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (
|
||||||
|
update.docChanged &&
|
||||||
|
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||||
|
) {
|
||||||
|
values.model.edit(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
const head = update.state.selection.main.head;
|
||||||
|
const line = update.state.doc.lineAt(head);
|
||||||
|
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || view.state.doc.toString() === snapshot.content) return;
|
||||||
|
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
|
||||||
|
selection: { anchor: head },
|
||||||
|
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||||
|
});
|
||||||
|
}, [snapshot.content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !location.lineStart) return;
|
||||||
|
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||||
|
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||||
|
const from = view.state.doc.line(lineStart).from;
|
||||||
|
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||||
|
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||||
|
});
|
||||||
|
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
||||||
|
});
|
||||||
|
}, [filename]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
||||||
|
if (!vimEnabled) {
|
||||||
|
onVimModeChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cm = getCM(view);
|
||||||
|
if (!cm) return;
|
||||||
|
function handleModeChange(event: { mode?: string }) {
|
||||||
|
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||||
|
}
|
||||||
|
cm.on("vim-mode-change", handleModeChange);
|
||||||
|
onVimModeChange("NORMAL");
|
||||||
|
return () => cm.off("vim-mode-change", handleModeChange);
|
||||||
|
}, [onVimModeChange, vimEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
data-pmono=""
|
||||||
|
data-testid="file-source-editor"
|
||||||
|
aria-label={`Source editor for ${filename}`}
|
||||||
|
style={HOST_STYLE}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteUpdate = Annotation.define<boolean>();
|
||||||
|
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user