Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0d88211636 fix(terminal): preserve adjacent wide characters
Terminal snapshots converted zero-width continuation cells into spaces, which altered captured and restored output.
2026-07-22 12:13:27 +00:00
Mohamed Boudra 68993b7ab3 fix(desktop): preserve Windows terminal hook smoke command
The outer batch shell expanded the hook variable and control operators before terminal send-keys received them. Encode the probe for PowerShell so expansion happens inside the packaged terminal.
2026-07-22 13:16:27 +02:00
Mohamed Boudra 246c07fba5 fix(cli): make new workspace creation explicit (#2315)
Agent callers now stay in their current workspace unless --new-workspace explicitly requests a separate local or worktree workspace.
2026-07-22 18:26:40 +08:00
Mohamed Boudra 0afcc96370 docs(website): correct provider usage guidance 2026-07-22 12:25:01 +02:00
Mohamed Boudra 3a8d08ba91 Refactor changelog entries and improve descriptions 2026-07-22 12:25:01 +02:00
paseo-ai[bot] f22a8ea8e0 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-22 09:05:28 +00:00
Mohamed Boudra 89a022e853 chore(release): cut 0.2.0-beta.2 2026-07-22 10:56:55 +02:00
Mohamed Boudra 14b25d4266 docs(changelog): add 0.2.0 beta 2 notes 2026-07-22 10:34:05 +02:00
Mohamed Boudra 6cddc657cd chore(acp): refresh provider catalog versions 2026-07-22 10:33:51 +02:00
Mohamed Boudra 14acb97662 fix(server): preserve Pi message IDs after resume (#2313) 2026-07-22 10:30:01 +02:00
Mohamed Boudra 4a4556f499 test(app): expand limited sidebar group in scroll coverage 2026-07-22 00:15:58 +02:00
Mohamed Boudra 21d9bdc644 fix(app): stop composer flickering during keyboard changes
Keep layout-only styles on stable React Native style IDs when they share Reanimated hosts with keyboard transforms. This prevents style regeneration from disturbing the animated native views while preserving keyboard and safe-area behavior.
2026-07-21 23:56:23 +02:00
Mohamed Boudra 97cfcf8306 fix(app): stop markdown lines joining
Custom line-break rules dropped the resolved full-width break style, allowing adjacent native text spans to collapse onto one line. Preserve the break styles in both chat and shared Markdown renderers.
2026-07-21 23:56:23 +02:00
Mohamed Boudra 9603397aed perf(app): limit initial sidebar workspace rendering
Large project, status, and pinned groups now render 20 rows until expanded. Newly discovered workspaces are prepended without disturbing the saved order.
2026-07-21 23:56:22 +02:00
Mohamed Boudra 309672c8e5 Show recent commit history in the explorer (#2312)
* feat(app): show recent commit history in explorer

* fix(app): keep recent commit history accurate

* fix(app): include merged commits in recent history

* perf(app): bound recent commit classification

* fix(app): refresh recent commits on Git updates
2026-07-21 22:12:33 +02:00
Mohamed Boudra 2ed1f4f353 Merge branch 'main' of github.com:getpaseo/paseo 2026-07-21 21:36:08 +02:00
Mohamed Boudra cb6d2f1459 Open chat file links at the referenced line (#2309)
* fix(app): open chat file links at referenced lines

Pass chat file locations through the editable CodeMirror path so opening a reference selects and scrolls to its requested line. Cover the real assistant-link flow with a browser regression.

* fix(app): repeat chat file navigation

Track file navigation separately from stable tab identity so reopening the same path and line recenters both editable and read-only panes. Extend the browser regression to cover moving away and clicking the same link again.

* test(app): stabilize repeated file link navigation
2026-07-21 21:34:20 +02:00
Mohamed Boudra cf9dfcf947 fix(app): stop Android chat jumping during streaming
The inverted list's native position maintenance fought the sticky-bottom controller as the live message grew. Let the controller exclusively own the Android sticky-bottom anchor while preserving position maintenance when reading history.
2026-07-21 21:33:47 +02:00
Mohamed Boudra 7d10791bad test(app): verify catch-up after actual unsubscription 2026-07-21 20:49:58 +02:00
Mohamed Boudra 2cc1886a6b fix(app): standardize the Vim settings switch 2026-07-21 20:31:12 +02:00
Mohamed Boudra d27c7ad590 fix(app): prevent catch-up from moving submitted messages
Timeline catch-up treated the first unmatched optimistic prompt as an insertion boundary, which could move later acknowledged prompts behind assistant output. Apply canonical entries directly and preserve submission slots during replacement.
2026-07-21 20:28:27 +02:00
Mohamed Boudra a4d11cda2b fix(app): keep hidden chats current through brief switches 2026-07-21 20:25:50 +02:00
Mohamed Boudra c049e86fee fix(app): keep submitted prompts in place
Live canonical user rows consumed the oldest optimistic prompt even when a client message ID identified a later submission. Match identified rows exactly while retaining FIFO reconciliation for legacy rows.
2026-07-21 19:31:40 +02:00
Mohamed Boudra 5c93ac4aa5 fix(app): balance sidebar divider spacing 2026-07-21 19:10:59 +02:00
Mohamed Boudra 9ca790df6a fix(app): simplify commits sidebar header 2026-07-21 18:23:44 +02:00
Mohamed Boudra ee431bb340 fix(desktop): restore packaged terminal hooks
The desktop daemon resolved the CLI through a packaged module entrypoint that is not an executable outside the archive. Publish the existing bundled shim as the daemon's authoritative CLI path so terminal hooks use a callable command.
2026-07-21 17:55:13 +02:00
Mohamed Boudra 0707092131 fix(app): preserve UI fonts in portaled overlays
The web UI font rule only targeted the app root, so portaled content fell back to the default font. Extend the rule to the shared overlay root and cover it with a browser regression test.
2026-07-21 17:33:31 +02:00
Mohamed Boudra b2139b1400 fix(app): keep agent history visible during catch-up
Visibility catch-up could select the blocking overlay after optimistic continuity cleared, even though history was already hydrated. Keep hydrated content non-blocking and cover the creation handoff with a browser regression test.
2026-07-21 16:17:56 +02:00
Mohamed Boudra a8fb40e689 fix(app): align sidebar section spacing 2026-07-21 15:59:55 +02:00
Mohamed Boudra 25cdda9492 feat(app): label sidebar add project action 2026-07-21 15:46:53 +02:00
Mohamed Boudra d6dc309408 fix(app): keep pinned workspace labels aligned
Reserve the leading status slot for idle pinned rows so labels do not shift when workspace status changes.
2026-07-21 15:32:22 +02:00
Mohamed Boudra 6755341fbb Merge branch 'main' of github.com:getpaseo/paseo 2026-07-21 15:26:27 +02:00
Mohamed Boudra 2cb1b041dd Start new workspaces from pasted pull requests (#2290)
* fix(workspaces): start pasted pull requests from their branch

Recognized pull request links now select their branch directly instead of
waiting for a separate confirmation. Ensure single-branch clones also record
the fetched branch so the new worktree can track it.

* fix(workspaces): keep pasted PR checkout target-safe

* fix(workspaces): make pasted PR resolution deterministic

* fix(workspaces): preserve explicit checkout selection

A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments.

* fix(composer): ignore stale pull request lookups

A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it.

* fix(workspaces): make checkout choices authoritative

Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups.

* fix(workspaces): stabilize pull request auto-selection

Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout.

* fix(composer): release removed pull request lookups

Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
2026-07-21 15:20:05 +02:00
Mohamed Boudra 5605b2aa94 fix(server): silence non-Git workspace warnings
Expected rev-parse failures were logged like unexpected discovery errors after Git detection became fail-open. Keep ordinary non-Git results silent while retaining diagnostics for genuine process failures.
2026-07-21 15:06:42 +02:00
Mohamed Boudra fd4e13735c fix(app): prevent duplicate web menu actions 2026-07-21 14:04:26 +02:00
Mohamed Boudra 6acc82e9d3 fix(pi): preserve discovered project prompts
Append Paseo instructions from the integration extension because Pi's append-system-prompt flag suppresses automatic APPEND_SYSTEM.md discovery.
2026-07-21 13:44:23 +02:00
Mohamed Boudra d0456b1943 fix(sync): preserve host replicas across provider remounts
Make the host runtime own session and setup replicas for the registered host lifetime. Provider remounts can then reattach without clearing directory snapshots or timeline cursors.
2026-07-21 13:44:23 +02:00
Mohamed Boudra 8aa55db1e8 feat(app): improve workspace service controls
Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.

Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
2026-07-21 13:44:23 +02:00
Mohamed Boudra afeb4d18f8 fix(app): keep reload splash continuous
Treat desktop settings evaluation as part of daemon startup so restored app chrome cannot render between bootstrap phases.
2026-07-21 13:44:23 +02:00
Mohamed Boudra f214261ae5 perf(sync): reduce timeline catch-up page size 2026-07-21 13:44:23 +02:00
Alberto De AgostiniandMohamed Boudra aa6384babd Fix renaming projects before their first workspace (#2252)
* fix: emit project.update on rename so empty projects update

The rename handler only re-emitted workspace descriptors, which left
empty projects (no workspaces yet) showing the stale name on the client.

* test(projects): cover renaming empty projects

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-20 23:43:05 +02:00
True Byte ddb6d97bf0 feat(pi): add 'max' thinking level support (#2267)
* feat(pi): add 'max' thinking level support

Pi recently introduced a new 'max' thinking level beyond 'xhigh'.
This adds it to the type union, the UI options array, and the
runtime type guard so Paseo users can select it.

Changes:
- rpc-types.ts: add 'max' to PiThinkingLevel union
- agent.ts: add max entry to PI_THINKING_OPTIONS
- agent.ts: add 'max' to isPiThinkingLevel guard

* fix(pi): clarify xhigh description after adding max level

xhigh's 'Maximum reasoning' is now misleading since max is the true
maximum. Changed to 'Very deep reasoning' per review feedback.
2026-07-20 21:39:46 +00:00
nikuscs 187561e433 fix: workspace-scoped session imports across Claude Code, OpenCode, Pi, and OMP (#2265)
* fix(claude): scope import discovery to workspace

* fix(providers): preserve workspace session discovery
2026-07-21 05:04:03 +08:00
paseo-ai[bot] 045de763f7 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-20 20:59:35 +00:00
Mohamed Boudra 1962816c1e Allow turning thinking off (#2257)
* feat(providers): allow turning thinking off

Expose Off only for models that support disabled thinking while preserving Low as the default.

* fix(providers): reset unsupported thinking on model change

Keep the selected thinking option only when the new model advertises it; otherwise use that model's default and persist the runtime change.

* fix(providers): isolate custom model capabilities

Use strict manifest identity when reconciling thinking options so provider-prefixed custom models cannot inherit capabilities they do not advertise.

* fix(providers): clear thinking with default model

* fix(providers): validate disabled thinking support

* fix(providers): validate initial thinking config

* refactor(providers): centralize thinking capability

* fix(server): order config mutation events
2026-07-21 04:52:06 +08:00
维她命@andClaude Opus 4.8 8c92c7d423 Fix settings host sections showing "host not found" when the local daemon is stopped (#1749)
The local daemon's serverId persists while the daemon is stopped, so it isn't
among the connected hosts. The settings host-section resolver fell back to it
without checking it was connected, resolving the section to an unknown id and
rendering "host not found".

Extract the fallback into resolveActiveHostServerId, which only uses a serverId
when it names a currently connected host (covering both the picker selection and
the local daemon), and add regression tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 04:48:34 +08:00
Mohamed Boudra 345d8e5240 fix(app): keep cached workspace header visible while reconnecting
Checkout refresh was replacing known workspace identity with a skeleton. Keep persisted identity visible until fresh checkout details arrive.
2026-07-20 22:41:54 +02:00
Mohamed Boudra 4bda2dfea9 Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
2026-07-21 04:41:19 +08:00
Matt Cowger 9292f58896 feat(server): configure workspace service port allocation (#2165)
* starr

* fix(server): honor service port allocator contracts

* fix(server): pass workspace context to port scripts

* fix(server): cancel released service port plans

* test(server): support Windows port allocator fixtures

* docs(worktrees): clarify shell-less portScript execution
2026-07-20 20:50:01 +02:00
Mohamed Boudra 2ead7e7719 fix(app): keep dictation shortcuts responsive (#2268)
The composer mirrored dictation state in a second ref that could remain stale after the recording surface changed. Route shortcut decisions through the dictation lifecycle's synchronous state so finishing or rejecting a recording cannot leave Command-D latched.
2026-07-20 19:32:50 +02:00
Mohamed Boudra b3b1283d3b Show OpenCode follow-ups after background work (#2258)
* fix(opencode): surface autonomous parent turns

OpenCode extensions can prompt an idle session without Paseo owning an active turn. Adopt exact-session activity while keeping child permissions unbound.

* refactor(opencode): centralize session ownership guard

Keep autonomous turn ownership in the shared exact-session check so all qualifying events use one source of truth.

* fix(opencode): serialize autonomous handoff

Abort and await provider-side autonomous work before starting a direct Paseo prompt so late output cannot leak into the new turn.

* fix(opencode): reject stale interrupted activity

Keep late output from an interrupted Paseo turn out of autonomous admission until a real user message establishes the next run.

* fix(opencode): fence interrupted turn events

Use provider abort settlement and terminal events as the interrupted-turn boundary so delayed canceled user messages cannot resurrect old work.

* fix(opencode): recover from failed aborts

* fix(opencode): preserve timed-out abort fence

* refactor(opencode): model provider turn lifecycle

* fix(opencode): recover stop fence after stream loss
2026-07-20 19:14:57 +02:00
Mohamed Boudra e1bda8e498 fix(app): keep submitted prompts in timeline order (#2259)
Canonical provider message IDs and optimistic client IDs occupy different namespaces. Preserve both identities for deterministic reconciliation while retaining a dated content fallback for older daemon timelines.
2026-07-20 17:29:52 +02:00
Mohamed Boudra 07d988488e fix(sync): show complete workspace and agent history (#2263)
Cached focused state was leaking into the sidebar before authoritative workspace hydration, while catch-up limits counted raw events instead of projected entries. Keep the focused cache fast, gate directory presentation on hydration, and preserve canonical cursors while paging projected history.
2026-07-20 23:24:45 +08:00
yhori 0c68b26a8b fix(nix): package local speech worker (#1587)
* fix(nix): package local speech worker

* fix(nix): reuse sherpa package helper

* fix(nix): patch sherpa-onnx prebuilt binaries for NixOS libstdc++

The prebuilt sherpa-onnx-linux-x64 .so files link against libstdc++.so.6
which is not on the NixOS library path. Add autoPatchelfHook to fix ELF
RPATHs and stdenv.cc.cc.lib to provide the C++ runtime, resolving the
"Failed to load model because protobuf parsing failed" SIGABRT at
speech worker startup.
2026-07-20 09:21:10 +00:00
Mohamed Boudra 3d86c738ff fix(codex): stop phantom parent subagents (#2214) 2026-07-19 09:10:11 +02:00
240 changed files with 10678 additions and 2505 deletions
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 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
### Added
+4
View File
@@ -190,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`.
},
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: {
openai: {
+2
View File
@@ -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 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`.
---
+10 -1
View File
@@ -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
> 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,7 +383,7 @@ 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.
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
npm run cli -- ls -a -g # List all agents globally
+8 -2
View File
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
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
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
@@ -70,7 +70,13 @@ Two escape hatches in the codebase:
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
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
+3 -1
View File
@@ -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.
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.
@@ -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.
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.
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.
+1 -1
View File
@@ -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>))
```
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.
+21 -8
View File
@@ -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.
## 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
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
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.
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
catch-up completes; cached partial output is never presented as current history.
After grace has expired, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
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
live prefix.
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
history. This keeps late history before a newly submitted prompt without duplicating an
acknowledged prompt.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
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
+1 -1
View File
@@ -1 +1 @@
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
+8 -5
View File
@@ -5,6 +5,7 @@
nodejs_22,
python3,
makeWrapper,
autoPatchelfHook,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
@@ -59,10 +60,13 @@ buildNpmPackage rec {
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
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
@@ -71,10 +75,9 @@ buildNpmPackage rec {
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
# speech runtime ships prebuilt platform packages and is copied into the
# daemon closure by scripts/trace-daemon.mjs.
npm rebuild node-pty
# 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
# (@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
# truth for what the daemon needs at $out — auditable in plain JS, no
# npm hoisting / .bin / workspace-symlink footguns.
+87 -32
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -2531,10 +2531,35 @@
"optional": 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": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -2554,22 +2579,33 @@
"@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": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
@@ -11055,6 +11091,19 @@
"@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": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -35162,8 +35211,13 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"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/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -35178,6 +35232,7 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -36181,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/server": "0.2.0-beta.1",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/server": "0.2.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36432,10 +36487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36446,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36689,7 +36744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37585,9 +37640,9 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37817,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37830,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38048,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/highlight": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/highlight": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38593,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -132,6 +132,8 @@
"ws": "^8.20.0"
},
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0",
+333
View File
@@ -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();
});
});
+70
View File
@@ -328,6 +328,12 @@ interface PairingDaemonClient {
async function createFakeEditorBin(): Promise<string> {
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 fs = require("fs");
@@ -349,6 +355,70 @@ if (recordPath) {
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;
}
@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
daemonListen?: string;
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
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
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
@@ -101,6 +103,7 @@ declare global {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
__desktopDaemonStartRequested?: boolean;
}
}
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
window.__desktopDaemonStartRequested = false;
function buildDaemonStatus() {
return {
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
function startDesktopDaemon() {
window.__desktopDaemonStartRequested = true;
if (cfg.hangDaemonStart) {
return new Promise(() => undefined);
}
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return buildDaemonStatus();
}
async function waitForDesktopSettingsResponse() {
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
if (delayMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
const desktopBridge: {
platform: string;
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") {
await waitForDesktopSettingsResponse();
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}, 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(
page: Page,
): Promise<Record<string, unknown> | undefined> {
+61 -1
View File
@@ -1,5 +1,5 @@
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";
export function hasGithubAuth(): boolean {
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
cleanup(): Promise<void>;
}
export interface LocalGhPrFixture {
pr: GhPrFixture;
mainCheckout: GhDefaultBranchClone;
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
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 });
},
};
}
+20 -1
View File
@@ -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 { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -290,6 +290,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
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> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
@@ -351,6 +358,18 @@ export async function expectComposerGithubAttachmentPill(
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(
page: Page,
input: {
+1
View File
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
+11 -63
View File
@@ -7,6 +7,10 @@ interface SessionMessage {
payload?: unknown;
}
interface TimelineSubscriptionWaitOptions {
timeout?: number;
}
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
if (typeof message !== "string") return null;
try {
@@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) {
});
return {
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
): Promise<void> {
const expected = [...new Set(agentIds)].sort();
await expect
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
timeout: options.timeout ?? 15_000,
})
.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]);
});
}),
);
},
};
}
+107
View File
@@ -17,11 +17,14 @@ import {
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
pasteGithubPrUrl,
searchAndSelectBranchInPicker,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
@@ -30,9 +33,11 @@ import {
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
createLocalGithubPrFixture,
cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo,
hasGithubAuth,
type LocalGhPrFixture,
} from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
test.describe.configure({ timeout: 240_000 });
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
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 }) => {
const repo = await createTempGitRepo("new-workspace-project-picker-");
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 ({
page,
}) => {
@@ -219,6 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
+19 -28
View File
@@ -3,10 +3,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "./fixtures";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import {
observeLastAssistantFrames,
observeTimelineSubscriptions,
} from "./helpers/timeline-delivery";
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
@@ -75,47 +72,40 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
}
test.describe("Viewed agent timelines", () => {
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
page,
}) => {
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
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 subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
expect(finish.status).toBe("idle");
const assistantFrames = await observeLastAssistantFrames(page);
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
await commitMessage(
scenario,
scenario.firstAgentId,
"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 expect(
page.getByText("Committed after the first chat unsubscribed.", { 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 {
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();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
await commitMessage(
scenario,
scenario.firstAgentId,
@@ -128,6 +118,7 @@ test.describe("Viewed agent timelines", () => {
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
} finally {
await scenario.cleanup();
}
@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
@@ -43,6 +44,49 @@ async function fetchActiveAgentTitle(
}
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 ({
page,
}) => {
@@ -31,11 +31,52 @@ import {
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
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 { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
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(
page: Page,
@@ -239,7 +280,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 daemonGate = await installDaemonWebSocketGate(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
@@ -254,6 +297,7 @@ test.describe("Workspace navigation regression", () => {
serverId,
manageBuiltInDaemon: true,
hangDaemonStart: true,
desktopSettingsDelayMs: 250,
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
});
await openWorkspaceThroughApp(page, { serverId, workspace });
@@ -261,14 +305,16 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceTabVisible(page, agent.id);
await expectWorkspaceLocation(page, { serverId, workspace });
await observeStartupPresentation(page);
await daemonGate.drop();
await page.reload();
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
await waitForDesktopDaemonStartRequest(page);
daemonGate.restore();
await waitForSidebarHydration(page);
await expectWorkspaceLocation(page, { serverId, workspace });
await waitForWorkspaceTabsVisible(page);
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
} finally {
daemonGate.restore();
await workspace.cleanup();
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.0-beta.1",
"version": "0.2.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -32,6 +32,11 @@
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},
"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/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -46,6 +51,7 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -11,6 +11,7 @@ import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
@@ -189,6 +190,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
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(() => {
streamViewportMetricsRef.current = {
@@ -414,7 +421,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
maintainVisibleContentPosition={maintainVisibleContentPosition}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
+1 -7
View File
@@ -60,7 +60,6 @@ import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
@@ -337,7 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -376,11 +374,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
}, []);
const splashError =
@@ -15,7 +15,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
Copy,
@@ -25,7 +24,7 @@ import {
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
@@ -154,7 +153,7 @@ function TreeRowItem({
<View style={styles.entryIcon}>
{(() => {
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" />;
return <TreeChevron expanded={isExpanded} />;
+90 -13
View File
@@ -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({
theme,
label,
@@ -494,6 +552,12 @@ function SidebarFooter({
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
@@ -501,14 +565,6 @@ function SidebarFooter({
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
@@ -924,9 +980,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroup: {
paddingTop: theme.spacing[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],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
@@ -1008,8 +1061,8 @@ const styles = StyleSheet.create((theme) => ({
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
@@ -1020,6 +1073,30 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
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: {
width: 28,
height: 28,
@@ -569,8 +569,26 @@ export function createSharedMarkdownRules(): RenderRules {
{children}
</MarkdownInheritedText>
),
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
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: (
node: ASTNode,
_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;
}
+23 -4
View File
@@ -1689,10 +1689,29 @@ export const AssistantMessage = memo(function AssistantMessage({
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
// Android keep the same "\n" they rendered before.
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
// the resolved break styles: hardbreak is a full-width flex-row child on
// Android, and dropping that width joins the surrounding text spans.
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: (
node: ASTNode,
_children: ReactNode[],
@@ -94,6 +94,8 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
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 {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -1632,6 +1634,12 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
@@ -1784,19 +1792,28 @@ function ProjectBlock({
if (!collapsed) {
if (project.workspaces.length > 0) {
projectChildren = (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
<>
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
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") {
projectChildren = (
@@ -2073,6 +2090,12 @@ function ProjectModeList({
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const {
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats);
const projectIconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -2303,7 +2326,6 @@ function ProjectModeList({
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
@@ -2331,7 +2353,18 @@ function ProjectModeList({
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<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>
) : null}
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
@@ -2399,7 +2432,9 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
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],
},
projectListContainer: {
@@ -47,6 +47,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
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 { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
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";
// Themed icon wrappers
@@ -93,6 +95,12 @@ export function SidebarStatusWorkspaceList({
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.togglePinnedCollapsed,
);
const {
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
const content = (
@@ -100,9 +108,9 @@ export function SidebarStatusWorkspaceList({
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed
? null
: pinnedWorkspaces.map((workspace) => (
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
@@ -116,10 +124,18 @@ export function SidebarStatusWorkspaceList({
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
onWorkspacePress={onWorkspacePress}
/>
))}
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
onPress={togglePinnedWorkspacesExpanded}
testID="sidebar-pinned-show-more"
/>
) : null}
</>
)}
</View>
) : null}
{listHeaderComponent}
@@ -189,38 +205,92 @@ function StatusGroupList({
return (
<>
{groups.map((group) => (
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.bucket}`}
>
{group.rows.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}
/>
))}
</View>
) : null}
</View>
<StatusGroupRows
key={group.bucket}
group={group}
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
projectNamesByKey={projectNamesByKey}
shortcutIndex={shortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
))}
</>
);
}
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
// legible; the host is appended after a middle dot once labels are active.
function buildStatusRowSubtitle({
@@ -723,7 +793,8 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
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],
},
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,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
@@ -934,11 +935,17 @@ function SplitPaneView({
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const modifiedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: paneTabIds,
});
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
retainedTabIds: modifiedPaneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(
@@ -10,6 +10,7 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
full: 9999,
},
borderWidth: {
1: 1,
@@ -102,12 +103,41 @@ describe("control geometry", () => {
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);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
expect(geometry.segmentedContainerXs.padding).toBe(0);
expect(geometry.segmentedContainerSm.padding).toBe(0);
expect(geometry.segmentedContainerMd.padding).toBe(0);
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 FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,8 +22,10 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
@@ -35,6 +37,7 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
const controlHeights = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
@@ -58,10 +62,6 @@ export const switchGeometry = {
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 {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
@@ -121,8 +121,6 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const segmentedContainerSmRadius = theme.borderRadius.md;
const segmentedContainerMdRadius = theme.borderRadius.lg;
const switchControl = {
minHeight: controlHeights.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
return {
buttonXs: {
minHeight: controlHeights.compact,
minHeight: controlHeights.tight,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: controlHeights.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
padding: 0,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
padding: 0,
},
segmentedSegmentXs: {
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.full,
},
segmentedLabelXs: {
fontSize: theme.fontSize.xs,
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
},
};
}
@@ -11,6 +11,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -33,6 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -216,12 +218,16 @@ function renderDropdownSurface(input: {
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})}
exiting={
isWeb
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{body}
</FloatingSurface>
@@ -496,6 +502,17 @@ export function DropdownMenuContent({
setOpen(false);
}, [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
useEffect(() => {
if (!open || !triggerRef.current) {
@@ -613,6 +630,34 @@ export function DropdownMenuContent({
</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 (
<Modal
visible={modalVisible}
@@ -622,27 +667,7 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
<View style={styles.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>
{overlay}
</Modal>
);
}
@@ -878,6 +903,11 @@ const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
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 });
export function SegmentedControl<T extends string>({
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
style,
testID,
}: SegmentedControlProps<T>) {
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
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 containerStyle = useMemo(
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
return {
container: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
gap: 2,
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
},
containerSm: {
...geometry.segmentedContainerSm,
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedSegmentMd,
},
segmentSelected: {
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
backgroundColor: theme.colors.foreground,
},
segmentHover: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface2,
},
segmentPressed: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface3,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedLabelMd,
},
labelSelected: {
color: theme.colors.foreground,
color: theme.colors.surface0,
},
};
});
+19 -21
View File
@@ -12,6 +12,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
@@ -23,13 +24,12 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -446,7 +446,6 @@ export function TooltipContent({
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: 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
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>,
getOverlayRoot(),
);
}
@@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
zIndex: OVERLAY_Z.tooltip,
},
content: {
paddingVertical: theme.spacing[1],
@@ -1,5 +1,5 @@
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 ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
@@ -665,7 +665,11 @@ export function WorkspaceDraftAgentTab({
});
const inputAreaWrapperStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
() => [
animatedStaticStyles.inputAreaWrapper,
{ paddingBottom: insets.bottom },
composerKeyboardStyle,
],
[insets.bottom, composerKeyboardStyle],
);
@@ -772,6 +776,12 @@ export function WorkspaceDraftAgentTab({
);
}
const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
},
});
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -795,10 +805,6 @@ const styles = StyleSheet.create((theme) => ({
configSection: {
gap: theme.spacing[3],
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
importPillRow: {
width: "100%",
paddingHorizontal: theme.spacing[4],
@@ -48,7 +48,9 @@ interface SearchCall {
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
initialText?: string;
onPullRequestDetected?: () => void;
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() {
const queryClient = new QueryClient({
defaultOptions: {
@@ -87,6 +97,8 @@ function createWrapper() {
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [searchClient, setSearchClient] = useState(client);
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
@@ -94,18 +106,22 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client,
client: searchClient,
isConnected: true,
serverId: "server-1",
cwd,
cwd: workingDirectory,
setAttachments,
onPullRequestDetected: input.onPullRequestDetected,
});
return {
text,
setText,
setSearchClient,
setWorkingDirectory,
attachments,
setAttachments,
isResolving: autoAttach.isResolving,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
};
}
@@ -121,14 +137,20 @@ describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
const onPullRequestDetected = vi.fn();
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
});
expect(result.current.isResolving).toBe(true);
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
await flushDebounce();
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 }]);
vi.useRealTimers();
});
@@ -208,4 +230,115 @@ describe("useComposerGithubAutoAttach", () => {
]);
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();
});
});
+130 -7
View File
@@ -4,6 +4,7 @@ import {
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
@@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput {
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
onPullRequestAdded?: (item: ForgeSearchItem) => void;
}
interface ComposerGithubAutoAttachResult {
isResolving: boolean;
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
}
@@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach(
const latestRef = useRef(params);
const removedRefKeysRef = 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;
useEffect(() => {
suppressRefsCarriedAcrossTargets({
params: latestRef.current,
previousTargetRef,
removedRefKeys: removedRefKeysRef.current,
});
notifyNewPullRequestRefs({
params: latestRef.current,
presentPullRequestKeysRef,
});
const refs = refsReadyForLookup({
params: latestRef.current,
removedRefKeys: removedRefKeysRef.current,
@@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach(
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(() => {
void attachRefs({
refs,
@@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach(
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
}).finally(releaseResolving);
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
releaseResolving();
};
}, [
params.text,
@@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach(
return useMemo(
() => ({
isResolving: resolvingRefCounts.size > 0,
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({
refs,
queryClient,
@@ -142,16 +242,28 @@ async function attachRef({
return;
}
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;
}
latestRef.current.setAttachments((current) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
return current;
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
return;
}
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({
@@ -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 {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
+20 -3
View File
@@ -3,6 +3,7 @@ import {
Pressable,
Text,
ActivityIndicator,
StyleSheet as RNStyleSheet,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
@@ -768,6 +769,8 @@ interface ComposerProps {
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
waitForGithubAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
@@ -777,6 +780,8 @@ interface ComposerProps {
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
onGithubPrDetected?: () => void;
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
@@ -981,6 +986,7 @@ export function Composer({
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
@@ -989,6 +995,8 @@ export function Composer({
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
onGithubPrDetected,
onGithubPrAutoAttach,
cwd,
clearDraft,
autoFocus = false,
@@ -1070,6 +1078,8 @@ export function Composer({
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected,
onPullRequestAdded: onGithubPrAutoAttach,
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
@@ -1907,7 +1917,7 @@ export function Composer({
);
const composerContainerStyle = useMemo(
() => [styles.container, keyboardAnimatedStyle],
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
[keyboardAnimatedStyle],
);
const inputAreaContainerStyle = useMemo(
@@ -1949,7 +1959,11 @@ export function Composer({
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,
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
@@ -2064,11 +2078,14 @@ export function Composer({
);
}
const styles = StyleSheet.create((theme: Theme) => ({
const animatedStaticStyles = RNStyleSheet.create({
container: {
flexDirection: "column",
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
+12 -77
View File
@@ -70,6 +70,7 @@ import {
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
stopRealtimeVoice,
} from "./state";
@@ -496,65 +497,6 @@ function handleDesktopKeyPressImpl(
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(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): HTMLElement | null {
@@ -916,22 +858,18 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
interface StartDictationContext {
dictationUnavailableMessage: string | null | undefined;
canStartDictation: () => boolean;
isDictatingRef: React.MutableRefObject<boolean>;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
if (ctx.dictationUnavailableMessage) {
ctx.isDictatingRef.current = false;
ctx.toast.error(ctx.dictationUnavailableMessage);
return;
}
if (!ctx.canStartDictation()) {
ctx.isDictatingRef.current = false;
return;
}
ctx.isDictatingRef.current = true;
await ctx.startDictation();
}
@@ -1276,16 +1214,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textInputRef.current?.blur?.();
},
runKeyboardAction: (action) =>
runKeyboardActionImpl(action, {
textInputRef,
isDictatingRef,
sendAfterTranscriptRef,
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
isDictationRecording: isDictationActive,
markTranscriptForSend: () => {
sendAfterTranscriptRef.current = true;
},
confirmDictation,
cancelDictation,
startDictationIfAvailable,
handleToggleRealtimeVoiceShortcut,
isRealtimeVoiceForCurrentAgent,
voice,
startDictation: startDictationIfAvailable,
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
}),
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
@@ -1369,6 +1309,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const {
isRecording: isDictating,
isRecordingActive: isDictationActive,
isProcessing: isDictationProcessing,
partialTranscript: _dictationPartialTranscript,
volume: dictationVolume,
@@ -1389,11 +1330,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enableDuration: true,
});
const isDictatingRef = useRef(isDictating);
useEffect(() => {
isDictatingRef.current = isDictating;
}, [isDictating]);
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
voice,
voiceServerId,
@@ -1420,7 +1356,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
startDictationIfAvailableImpl({
dictationUnavailableMessage,
canStartDictation,
isDictatingRef,
toast,
startDictation,
}),
@@ -4,12 +4,40 @@ import {
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
stopRealtimeVoice,
} from "./state";
const connected = { isConnected: true } 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", () => {
it("shows only the input when no voice overlay is active", () => {
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", () => {
function actions() {
const calls: string[] = [];
+58
View File
@@ -1,5 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { MessagePayload } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = "interrupt" | "queue";
@@ -45,6 +46,18 @@ interface SendActionContext {
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: {
client: DaemonClient | null;
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> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
@@ -387,8 +387,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const toast = useToast();
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
const clearSession = useSessionStore((state) => state.clearSession);
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
const setMessages = useSessionStore((state) => state.setMessages);
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
@@ -410,11 +408,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -524,17 +520,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[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(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
@@ -1327,13 +1312,5 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[client],
);
// Cleanup on unmount
useEffect(() => {
return () => {
clearWorkspaceSetupServer(serverId);
clearSession(serverId);
};
}, [clearSession, clearWorkspaceSetupServer, serverId]);
return children;
}
+22 -22
View File
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.32.0",
version: "0.33.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -59,10 +59,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"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",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.44", "--acp"],
command: ["npx", "-y", "cline@3.0.46", "--acp"],
},
{
id: "codebuddy-code",
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.19",
version: "0.1.20",
iconId: "deepagents",
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",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.31",
version: "0.2.35",
iconId: "dimcode",
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",
title: "Dirac",
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.",
version: "0.4.17",
version: "0.4.21",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.174.0",
version: "0.177.0",
iconId: "factory-droid",
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: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.9.14",
version: "0.9.20",
iconId: "fast-agent",
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.20", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -192,10 +192,10 @@ const CATALOG_DATA = [
title: "GLM Agent",
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.",
version: "1.1.4",
version: "1.3.0",
iconId: "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",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.27",
version: "1.1.29",
iconId: "nova",
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",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.48",
version: "1.1.2",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.11",
version: "0.20.1",
iconId: "qwen-code",
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",
+178
View File
@@ -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);
});
});
+302
View File
@@ -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;
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
export function useLiveFile(input: {
client: DaemonClient | null;
serverId: string;
cwd: string | null;
path: string | null;
enabled: boolean;
liveUpdates: boolean;
}) {
const queryClient = useQueryClient();
const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates);
const [version, setVersion] = useState<FileVersion | null>(null);
const latestVersion = useRef<FileVersion | null>(null);
const queryKey = useMemo(
() => ["workspaceFile", input.serverId, input.cwd, input.path] as const,
[input.cwd, input.path, input.serverId],
);
useEffect(() => {
latestVersion.current = null;
setVersion(null);
const { client, cwd, path } = input;
if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) {
setSubscriptionReady(!input.liveUpdates);
return;
}
let disposed = false;
let unsubscribe: (() => void) | null = null;
setSubscriptionReady(false);
void (async () => {
try {
const subscription = await client.subscribeFile({ cwd, path }, (next) => {
if (disposed) return;
latestVersion.current = next;
setVersion(next);
void queryClient.invalidateQueries({ queryKey });
});
if (disposed) {
subscription.unsubscribe();
return;
}
unsubscribe = subscription.unsubscribe;
latestVersion.current = subscription.initial;
setVersion(subscription.initial);
setSubscriptionReady(true);
} catch {
if (!disposed) setSubscriptionReady(true);
}
})();
return () => {
disposed = true;
unsubscribe?.();
};
}, [
input.client,
input.cwd,
input.enabled,
input.liveUpdates,
input.path,
queryClient,
queryKey,
input.serverId,
]);
const query = useFetchQuery({
queryKey,
enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady,
queryFn: async (): Promise<FileReadResult> => {
if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable.");
return input.client.readFile(input.cwd, input.path);
},
dataShape: "value",
staleTimeMs: 5_000,
});
useEffect(() => {
const observed = latestVersion.current;
if (
query.data &&
observed?.status === "ready" &&
query.data.modifiedAt !== observed.modifiedAt
) {
void queryClient.invalidateQueries({ queryKey });
}
}, [query.data, queryClient, queryKey]);
return { query, version };
}
@@ -1,6 +1,13 @@
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import {
ActivityIndicator,
Image as RNImage,
@@ -8,7 +15,7 @@ import {
Text,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -29,6 +36,14 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file";
import { FilePanelBar } from "./bar";
import { FileEditorModel, type FileEditorFile } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -42,9 +57,12 @@ interface FilePreviewBodyProps {
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -190,9 +208,10 @@ function FilePreviewBody({
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const filePath = location.path;
const isMarkdownFile =
@@ -240,7 +259,7 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, navigationRevision]);
if (isLoading && !preview) {
return (
@@ -363,15 +382,27 @@ export function FilePane({
serverId,
workspaceRoot,
location,
navigationRevision,
}: {
serverId: string;
workspaceRoot: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
const [resolvedPreview, setResolvedPreview] = useState<{
key: string | null;
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}>({ key: null, file: null, imageAttachment: null });
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
@@ -390,61 +421,359 @@ export function FilePane({
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const query = useQuery({
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
enabled: isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
}),
queryFn: async () => {
if (!client || !readTarget) {
return {
file: null as ExplorerFile | null,
error: t("workspace.terminal.hostDisconnected"),
};
}
try {
const file = await client.readFile(readTarget.cwd, readTarget.path);
const preview = await createFilePanePreview(file);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: null,
};
} catch (error) {
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
};
}
},
staleTime: 5_000,
refetchOnMount: true,
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
});
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
const { query, version } = useLiveFile({
client,
serverId,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
useEffect(() => {
let active = true;
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
void (async () => {
const nextPreview = await createFilePanePreview(query.data ?? null);
if (active) setResolvedPreview({ key, ...nextPreview });
})();
return () => {
active = false;
};
}, [query.data, readTarget]);
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
const imagePreviewUri = useAttachmentPreviewUrl(
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
);
const isMarkdown = isMarkdownPreview(preview, location.path);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
return (
<FilePanePresentation
serverId={serverId}
client={client}
readTarget={readTarget}
preview={preview}
version={version}
filename={getFileNameFromPath(location.path) ?? location.path}
markdownMode={isMarkdown ? markdownMode : undefined}
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={query.isFetching}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
);
}
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && isRenderedMarkdownFile(path);
}
function getFileErrorMessage(error: unknown, fallback: string): string | null {
if (!error) return null;
return error instanceof Error ? error.message : fallback;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
}): boolean {
return Boolean(
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
);
}
function FilePanePresentation({
serverId,
client,
readTarget,
preview,
version,
filename,
markdownMode,
onMarkdownModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: {
serverId: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
version: FileVersion | null;
filename: string;
markdownMode?: "preview" | "source";
onMarkdownModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
version={version}
filename={filename}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
{query.data?.error ? (
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
/>
) : null}
{errorMessage ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{query.data.error}</Text>
<Text style={styles.errorText}>{errorMessage}</Text>
</View>
) : null}
<FilePreviewBody
preview={query.data?.file ?? null}
isLoading={query.isFetching}
preview={preview}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
</View>
);
}
function EditableFilePane({
client,
cwd,
path,
preview,
version,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
navigationRevision,
}: {
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
version: FileVersion | null;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const session = useMemo(
() => ({
async read(): Promise<FileEditorFile> {
const file = await client.readFile(cwd, path);
if (file.kind !== "text") throw new Error("File is no longer text.");
return {
content: new TextDecoder().decode(file.bytes),
version: {
status: "ready",
cwd,
path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
},
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(
() =>
new FileEditorModel({
file: {
content: preview.content ?? "",
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
},
},
session,
}),
);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
useEffect(() => () => model.dispose(), [model]);
useEffect(() => {
if (version) model.receiveFileVersion(version);
}, [model, version]);
const handleReload = useCallback(() => {
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
...preview,
content: snapshot.content,
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
modifiedAt:
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelBar
size={
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
onOverwrite={handleOverwrite}
onReload={handleReload}
mode={mode}
onModeChange={onModeChange}
/>
{showSource ? (
<FileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
preview={renderedPreview}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
/>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -2,7 +2,11 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import {
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
} from "@/git/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
@@ -132,6 +136,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
});
it("invalidates recent commits when checkout status is pushed", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus()),
});
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated,
).toBe(false);
});
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
const queryClient = createQueryClient();
const pushedPr = prStatus({ requestId: "pr-1" });
@@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import equal from "fast-deep-equal/es6";
import {
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout,
@@ -49,6 +50,9 @@ export function applyCheckoutStatusUpdateFromEvent({
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
expireStaleDiffModeOverrides({
serverId,
cwd: payload.cwd,
@@ -7,7 +7,6 @@ import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { CommitRow } from "./commit-row";
import { dotStyles } from "./shared";
interface CommitsSectionProps {
serverId: string;
@@ -137,12 +136,6 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
{commitCount}
</Text>
)}
<View style={styles.legend}>
<View style={dotStyles.dotLocal} />
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendLocal")}</Text>
<View style={dotStyles.legendDotRemote} />
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
</View>
</Pressable>
{collapsed ? null : (
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
@@ -177,7 +170,6 @@ const styles = StyleSheet.create((theme) => ({
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
count: {
@@ -188,16 +180,6 @@ const styles = StyleSheet.create((theme) => ({
countSpacer: {
flex: 1,
},
legend: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
legendText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
list: {
paddingBottom: theme.spacing[1],
},
+2 -16
View File
@@ -13,11 +13,8 @@ export const DOT_SIZE = 8;
const REMOTE_DOT_OPACITY = 0.55;
/**
* Shared local/remote commit dot styles used by both the commit rows and the
* section legend so the two never drift.
*
* Semantics: a local-only commit is a hollow ring; a commit that has reached
* the remote is a subtle (dimmed) filled green dot.
* A local-only commit is a hollow ring; a commit that has reached the remote
* is a subtle (dimmed) filled green dot.
*/
export const dotStyles = StyleSheet.create((theme) => ({
dotLocal: {
@@ -37,15 +34,4 @@ export const dotStyles = StyleSheet.create((theme) => ({
opacity: REMOTE_DOT_OPACITY,
flexShrink: 0,
},
// Legend variant of the remote dot: same fill, with leading spacing so it
// separates from the preceding "local" label in the section header legend.
legendDotRemote: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.statusSuccess,
opacity: REMOTE_DOT_OPACITY,
flexShrink: 0,
marginLeft: theme.spacing[1],
},
}));
+17
View File
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import {
checkoutDiffQueryKey,
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient,
@@ -24,6 +25,8 @@ describe("checkout query keys", () => {
files: [],
});
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
@@ -62,6 +65,12 @@ describe("checkout query keys", () => {
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
@@ -102,6 +111,8 @@ describe("checkout query keys", () => {
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
@@ -128,6 +139,12 @@ describe("checkout query keys", () => {
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
+4
View File
@@ -62,6 +62,9 @@ export async function invalidateCheckoutGitQueriesForClient(
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
}),
queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
@@ -81,6 +84,7 @@ export async function invalidateCheckoutGitQueriesForServer(
const kinds = [
"checkoutStatus",
"checkoutPrStatus",
"checkoutCommits",
prPaneTimelineQueryKind,
prPanePipelineQueryKind,
];
@@ -471,7 +471,7 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
});
it("appends unseen projects and workspaces to the persisted orders", () => {
it("appends unseen projects while putting unseen workspaces before the saved order", () => {
const projects = [
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
@@ -485,11 +485,33 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
expect(updates.workspaceOrders).toEqual([
{ projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] },
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
{ projectKey: "project-b", order: ["srv:ws-3"] },
]);
});
it("preserves the saved workspace order behind multiple newly discovered workspaces", () => {
const projects = [
sidebarProject({
projectKey: "project-a",
workspaceKeys: ["newest", "newer", "old-a", "old-b"],
}),
];
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["project-a"],
getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"],
});
expect(updates.workspaceOrders).toEqual([
{
projectKey: "project-a",
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
},
]);
});
it("returns no project-order update when persisted order already covers visible keys", () => {
const projects = [
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }),
@@ -477,6 +477,23 @@ export function appendMissingOrderKeys(input: {
return [...input.currentOrder, ...missingKeys];
}
export function prependMissingOrderKeys(input: {
currentOrder: string[];
visibleKeys: string[];
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder;
}
const existingKeys = new Set(input.currentOrder);
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
if (missingKeys.length === 0) {
return input.currentOrder;
}
return [...missingKeys, ...input.currentOrder];
}
export interface SidebarOrderUpdates {
projectOrder: string[] | null;
workspaceOrders: Array<{ projectKey: string; order: string[] }>;
@@ -500,7 +517,7 @@ export function computeSidebarOrderUpdates(input: {
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
for (const project of input.projects) {
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
const nextWorkspaceOrder = appendMissingOrderKeys({
const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
});
@@ -209,7 +209,7 @@ describe("deriveAgentScreenViewState", () => {
expect(sync.ui).toBe("silent");
});
it("covers already-hydrated history while a newly visible agent catches up", () => {
it("keeps already-hydrated history visible while a newly visible agent catches up", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
@@ -225,7 +225,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.ui).toBe("silent");
});
it("keeps hydrated history readable after a visibility catch-up error", () => {
@@ -150,13 +150,11 @@ function resolveCatchingUpUi(args: {
hasOptimisticCreateContinuity: boolean;
isVisibilityCatchUpPending: boolean;
hasHydratedHistoryBefore: boolean;
needsAuthoritativeSync: boolean;
hadInitialSyncFailure: boolean;
}): "overlay" | "silent" {
if (args.hasOptimisticCreateContinuity) return "silent";
if (args.hasHydratedHistoryBefore && args.needsAuthoritativeSync) return "silent";
if (args.isVisibilityCatchUpPending) return "overlay";
if (args.hasHydratedHistoryBefore) return "silent";
if (args.isVisibilityCatchUpPending) return "overlay";
if (args.hadInitialSyncFailure) return "silent";
return "overlay";
}
@@ -186,7 +184,6 @@ function resolveAgentScreenSync(args: {
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
needsAuthoritativeSync: input.needsAuthoritativeSync,
hadInitialSyncFailure,
}),
};
@@ -15,6 +15,7 @@ export interface UseDictationOptions {
export interface UseDictationResult {
isRecording: boolean;
isRecordingActive: () => boolean;
isProcessing: boolean;
partialTranscript: string;
volume: number;
+2
View File
@@ -59,6 +59,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
useEffect(() => {
isRecordingRef.current = isRecording;
}, [isRecording]);
const isRecordingActive = useCallback(() => isRecordingRef.current, []);
const isProcessingRef = useRef(isProcessing);
useEffect(() => {
@@ -453,6 +454,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return {
isRecording,
isRecordingActive,
isProcessing,
partialTranscript,
volume: audio.volume,
@@ -191,6 +191,9 @@ export function useSettings<TSelected>(
if (updates.toolCallDetailLevel !== undefined) {
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
}
if (updates.vimKeybindings !== undefined) {
appUpdates.vimKeybindings = updates.vimKeybindings;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -43,6 +43,7 @@ export interface AppSettings {
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
vimKeybindings: boolean;
}
export interface Settings extends AppSettings {
@@ -66,6 +67,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
workspaceTitleSource: "title",
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
vimKeybindings: false,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -233,6 +235,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (typeof stored.vimKeybindings === "boolean") {
result.vimKeybindings = stored.vimKeybindings;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -89,13 +88,9 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
shallow,
);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
const hostProjects = useHostProjects(serverIds);
const hostProjects = useHostProjects(hydratedServerIds);
const sidebarModel = useMemo(
() =>
+54 -9
View File
@@ -466,6 +466,7 @@ export const ar: TranslationResources = {
},
tabs: {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: {
@@ -532,6 +533,13 @@ export const ar: TranslationResources = {
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
unsavedMessage:
"تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.",
closeWithoutSaving: "إغلاق بدون حفظ",
closePaneTitle: "إغلاق اللوحة؟",
bulkUnsaved:
"تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.",
close: "يغلق",
cancel: "يلغي",
archive: "أرشيف",
@@ -580,19 +588,34 @@ export const ar: TranslationResources = {
scripts: {
title: "البرامج النصية",
actions: {
chooseUrl: "اختيار الرابط",
copyUrl: "نسخ الرابط",
openService: "عرض الخدمة",
restart: "إعادة التشغيل",
run: "يجري",
view: "منظر",
stop: "إيقاف",
view: "عرض الوحدة الطرفية",
},
accessibility: {
trigger: "البرامج النصية Workspace",
openAt: "افتح{{scriptName}}في{{label}}",
openService: "عرض خدمة {{scriptName}}",
viewTerminal: "عرض محطة{{scriptName}}",
runScript: "قم بتشغيل البرنامج النصي{{scriptName}}",
stopScript: "إيقاف{{scriptName}}",
restartScript: "إعادة تشغيل{{scriptName}}",
copyUrl: "نسخ عنوان URL لـ{{scriptName}}",
chooseUrl: "اختيار رابط {{scriptName}}",
script: "البرنامج النصي{{scriptName}}",
},
routes: {
public: "الوكيل العكسي",
paseo: "Memorable",
direct: "مباشر",
},
states: {
exitCode: "الخروج من{{code}}",
startFailed: "فشل بدء تشغيل{{scriptName}}",
stopFailed: "فشل إيقاف{{scriptName}}",
},
},
git: {
@@ -765,14 +788,12 @@ export const ar: TranslationResources = {
deletedFile: "تم الحذف",
commits: {
title: "الإيداعات",
legendLocal: "محلي",
legendRemote: "على المستودع البعيد",
countLabel: "{{count}} إيداعات قبل الأساس",
countLabel: "{{count}} من الإيداعات الأخيرة",
fileDiffEmpty: "لا توجد تغييرات لعرضها",
fileDiffError: "تعذّر تحميل فروق الملف",
loading: "جارٍ تحميل الإيداعات…",
loadError: "تعذّر تحميل الإيداعات",
empty: "لا توجد إيداعات قبل الأساس",
empty: "لا توجد إيداعات بعد",
},
},
openInEditor: {
@@ -905,6 +926,8 @@ export const ar: TranslationResources = {
actions: {
menu: "إجراءات Workspace",
newWorkspace: "مساحة عمل جديدة",
showMore: "عرض المزيد",
showLess: "عرض أقل",
createWorkspaceFor: "قم بإنشاء مساحة عمل جديدة لـ{{projectName}}",
copyPath: "نسخ المسار",
copyBranchName: "انسخ اسم الفرع",
@@ -962,9 +985,6 @@ export const ar: TranslationResources = {
refPicker: {
startingRef: "بدء المرجع",
chooseStart: "اختر من أين تبدأ",
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
intoBase: "إلى {{baseRef}}",
searching: "جارٍ البحث...",
noMatchingRefs: "لا توجد مراجع مطابقة.",
@@ -1445,6 +1465,25 @@ export const ar: TranslationResources = {
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
fileSize: "حجم الملف {{size}}",
lines: "{{count}} سطر",
editorStatus: "حالة المحرر {{status}}",
unsavedChanges: "تغييرات غير محفوظة",
saving: "جارٍ الحفظ...",
saveFailed: "فشل الحفظ",
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
unavailableTitle: "الملف غير متاح على القرص",
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
overwrite: "استبدال",
reload: "إعادة تحميل",
reloadTitle: "إعادة التحميل من القرص؟",
reloadMessage: "ستفقد تغييراتك المحلية.",
},
},
diff: {
changesLabel: "التغييرات",
@@ -1540,12 +1579,18 @@ export const ar: TranslationResources = {
sections: {
general: "عام",
appearance: "مظهر",
editor: "المحرر",
shortcuts: "الاختصارات",
integrations: "التكامل",
permissions: "الأذونات",
diagnostics: "التشخيص",
about: "عن",
},
editor: {
title: "المحرر",
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
hostSections: {
connections: "اتصالات",
agents: "Agents",
+53 -9
View File
@@ -465,6 +465,7 @@ export const en = {
},
tabs: {
loading: "Loading...",
modified: "Unsaved changes",
loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.",
fallback: {
@@ -534,6 +535,12 @@ export const en = {
close: "Close",
cancel: "Cancel",
archive: "Archive",
unsavedTitle: "Unsaved changes",
unsavedMessage:
"This tab has changes that have not been saved. Closing it will discard the draft.",
closeWithoutSaving: "Close without saving",
closePaneTitle: "Close pane?",
bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.",
closeTerminalTitle: "Close terminal?",
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
archiveRunningAgentTitle: "Archive running agent?",
@@ -578,19 +585,34 @@ export const en = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "Choose URL",
copyUrl: "Copy URL",
openService: "View service",
restart: "Restart",
run: "Run",
view: "View",
stop: "Stop",
view: "View terminal",
},
accessibility: {
trigger: "Workspace scripts",
openAt: "Open {{scriptName}} at {{label}}",
openService: "View {{scriptName}} service",
viewTerminal: "View {{scriptName}} terminal",
runScript: "Run {{scriptName}} script",
stopScript: "Stop {{scriptName}}",
restartScript: "Restart {{scriptName}}",
copyUrl: "Copy {{scriptName}} URL",
chooseUrl: "Choose URL for {{scriptName}}",
script: "{{scriptName}} script",
},
routes: {
public: "Reverse proxy",
paseo: "Memorable",
direct: "Direct",
},
states: {
exitCode: "exit {{code}}",
startFailed: "Failed to start {{scriptName}}",
stopFailed: "Failed to stop {{scriptName}}",
},
},
git: {
@@ -776,14 +798,12 @@ export const en = {
deletedFile: "Deleted",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "on remote",
countLabel: "{{count}} commits ahead of base",
countLabel: "{{count}} recent commits",
fileDiffEmpty: "No changes to display",
fileDiffError: "Failed to load file diff",
loading: "Loading commits…",
loadError: "Failed to load commits",
empty: "No commits ahead of base",
empty: "No commits yet",
},
},
openInEditor: {
@@ -916,6 +936,8 @@ export const en = {
actions: {
menu: "Workspace actions",
newWorkspace: "New workspace",
showMore: "Show more",
showLess: "Show less",
createWorkspaceFor: "Create a new workspace for {{projectName}}",
copyPath: "Copy path",
copyBranchName: "Copy branch name",
@@ -973,9 +995,6 @@ export const en = {
refPicker: {
startingRef: "Starting ref",
chooseStart: "Choose where to start from",
checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint",
intoBase: "into {{baseRef}}",
searching: "Searching...",
noMatchingRefs: "No matching refs.",
@@ -1457,6 +1476,25 @@ export const en = {
binaryPreviewUnavailable: "Binary preview unavailable",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
fileSize: "File size {{size}}",
lines: "{{count}} lines",
editorStatus: "Editor status {{status}}",
unsavedChanges: "Unsaved changes",
saving: "Saving...",
saveFailed: "Save failed",
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
unavailableTitle: "File unavailable on disk",
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
overwrite: "Overwrite",
reload: "Reload",
reloadTitle: "Reload from disk?",
reloadMessage: "Your local changes will be lost.",
},
},
diff: {
changesLabel: "Changes",
@@ -1552,12 +1590,18 @@ export const en = {
sections: {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
diagnostics: "Diagnostics",
about: "About",
},
editor: {
title: "Editor",
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
hostSections: {
connections: "Connections",
agents: "Agents",
+54 -9
View File
@@ -470,6 +470,7 @@ export const es: TranslationResources = {
},
tabs: {
loading: "Cargando...",
modified: "Cambios sin guardar",
loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.",
fallback: {
@@ -537,6 +538,13 @@ export const es: TranslationResources = {
failedToReloadAgent: "No se pudo recargar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
unsavedMessage:
"Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.",
closeWithoutSaving: "Cerrar sin guardar",
closePaneTitle: "¿Cerrar panel?",
bulkUnsaved:
"{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.",
close: "Cerca",
cancel: "Cancelar",
archive: "Archivo",
@@ -586,19 +594,34 @@ export const es: TranslationResources = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "Elegir URL",
copyUrl: "Copiar URL",
openService: "Ver servicio",
restart: "Reiniciar",
run: "Correr",
view: "Vista",
stop: "Detener",
view: "Ver terminal",
},
accessibility: {
trigger: "GuionesWorkspace",
openAt: "Abrir{{scriptName}}en{{label}}",
openService: "Ver servicio {{scriptName}}",
viewTerminal: "Ver terminal{{scriptName}}",
runScript: "Ejecute el script{{scriptName}}",
stopScript: "Detener{{scriptName}}",
restartScript: "Reiniciar{{scriptName}}",
copyUrl: "Copiar URL de{{scriptName}}",
chooseUrl: "Elegir URL para {{scriptName}}",
script: "Guión{{scriptName}}",
},
routes: {
public: "Proxy inverso",
paseo: "Memorable",
direct: "Directa",
},
states: {
exitCode: "salir de{{code}}",
startFailed: "No se pudo iniciar{{scriptName}}",
stopFailed: "No se pudo detener{{scriptName}}",
},
},
git: {
@@ -796,14 +819,12 @@ export const es: TranslationResources = {
deletedFile: "Eliminado",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "en remoto",
countLabel: "{{count}} commits por delante de la base",
countLabel: "{{count}} commits recientes",
fileDiffEmpty: "No hay cambios para mostrar",
fileDiffError: "Error al cargar el diff del archivo",
loading: "Cargando commits…",
loadError: "Error al cargar los commits",
empty: "No hay commits por delante de la base",
empty: "Aún no hay commits",
},
},
openInEditor: {
@@ -936,6 +957,8 @@ export const es: TranslationResources = {
actions: {
menu: "AccionesWorkspace",
newWorkspace: "Nuevo espacio de trabajo",
showMore: "Mostrar más",
showLess: "Mostrar menos",
createWorkspaceFor: "Crea un nuevo espacio de trabajo para{{projectName}}",
copyPath: "Copiar ruta",
copyBranchName: "Copiar nombre de sucursal",
@@ -993,9 +1016,6 @@ export const es: TranslationResources = {
refPicker: {
startingRef: "Árbitro inicial",
chooseStart: "Elige por dónde empezar",
checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}",
intoBase: "en {{baseRef}}",
searching: "Búsqueda...",
noMatchingRefs: "No hay árbitros coincidentes.",
@@ -1488,6 +1508,25 @@ export const es: TranslationResources = {
binaryPreviewUnavailable: "Vista previa binaria no disponible",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
fileSize: "Tamaño {{size}}",
lines: "{{count}} líneas",
editorStatus: "Estado del editor: {{status}}",
unsavedChanges: "Cambios sin guardar",
saving: "Guardando...",
saveFailed: "Error al guardar",
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
unavailableTitle: "Archivo no disponible en disco",
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
overwrite: "Sobrescribir",
reload: "Recargar",
reloadTitle: "¿Recargar desde el disco?",
reloadMessage: "Se perderán tus cambios locales.",
},
},
diff: {
changesLabel: "Cambios",
@@ -1583,12 +1622,18 @@ export const es: TranslationResources = {
sections: {
general: "General",
appearance: "Apariencia",
editor: "Editor",
shortcuts: "Atajos",
integrations: "Integraciones",
permissions: "Permisos",
diagnostics: "Diagnóstico",
about: "Acerca de",
},
editor: {
title: "Editor",
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
hostSections: {
connections: "Conexiones",
agents: "Agents",
+54 -9
View File
@@ -470,6 +470,7 @@ export const fr: TranslationResources = {
},
tabs: {
loading: "Chargement...",
modified: "Modifications non enregistrées",
loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.",
fallback: {
@@ -537,6 +538,13 @@ export const fr: TranslationResources = {
failedToReloadAgent: "Échec du rechargement de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
unsavedMessage:
"Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.",
closeWithoutSaving: "Fermer sans enregistrer",
closePaneTitle: "Fermer le volet?",
bulkUnsaved:
"{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.",
close: "Fermer",
cancel: "Annuler",
archive: "Archive",
@@ -586,19 +594,34 @@ export const fr: TranslationResources = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "Choisir l’URL",
copyUrl: "Copier l’URL",
openService: "Voir le service",
restart: "Redémarrer",
run: "Courir",
view: "Voir",
stop: "Arrêter",
view: "Voir le terminal",
},
accessibility: {
trigger: "ScriptsWorkspace",
openAt: "Ouvrir{{scriptName}}à{{label}}",
openService: "Voir le service {{scriptName}}",
viewTerminal: "Voir le terminal{{scriptName}}",
runScript: "Exécuter le script{{scriptName}}",
stopScript: "Arrêter{{scriptName}}",
restartScript: "Redémarrer{{scriptName}}",
copyUrl: "Copier l'URL de{{scriptName}}",
chooseUrl: "Choisir l’URL pour {{scriptName}}",
script: "Script{{scriptName}}",
},
routes: {
public: "Proxy inverse",
paseo: "Memorable",
direct: "Directe",
},
states: {
exitCode: "quitter{{code}}",
startFailed: "Échec du démarrage de{{scriptName}}",
stopFailed: "Échec de l'arrêt de{{scriptName}}",
},
},
git: {
@@ -795,14 +818,12 @@ export const fr: TranslationResources = {
deletedFile: "Supprimé",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "sur le distant",
countLabel: "{{count}} commits en avance sur la base",
countLabel: "{{count}} commits récents",
fileDiffEmpty: "Aucune modification à afficher",
fileDiffError: "Échec du chargement du diff du fichier",
loading: "Chargement des commits…",
loadError: "Échec du chargement des commits",
empty: "Aucun commit en avance sur la base",
empty: "Aucun commit pour le moment",
},
},
openInEditor: {
@@ -935,6 +956,8 @@ export const fr: TranslationResources = {
actions: {
menu: "ActionsWorkspace",
newWorkspace: "Nouvel espace de travail",
showMore: "Afficher plus",
showLess: "Afficher moins",
createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}",
copyPath: "Copier le chemin",
copyBranchName: "Copier le nom de la branche",
@@ -992,9 +1015,6 @@ export const fr: TranslationResources = {
refPicker: {
startingRef: "Réf de départ",
chooseStart: "Choisissez par où commencer",
checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?",
checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}",
intoBase: "dans {{baseRef}}",
searching: "Recherche...",
noMatchingRefs: "Aucune référence correspondante.",
@@ -1491,6 +1511,25 @@ export const fr: TranslationResources = {
binaryPreviewUnavailable: "Aperçu binaire indisponible",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
fileSize: "Taille {{size}}",
lines: "{{count}} lignes",
editorStatus: "État de l’éditeur : {{status}}",
unsavedChanges: "Modifications non enregistrées",
saving: "Enregistrement...",
saveFailed: "Échec de l’enregistrement",
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
unavailableTitle: "Fichier indisponible sur le disque",
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
overwrite: "Écraser",
reload: "Recharger",
reloadTitle: "Recharger depuis le disque ?",
reloadMessage: "Vos modifications locales seront perdues.",
},
},
diff: {
changesLabel: "Modifications",
@@ -1586,12 +1625,18 @@ export const fr: TranslationResources = {
sections: {
general: "Général",
appearance: "Apparence",
editor: "Éditeur",
shortcuts: "Raccourcis",
integrations: "Intégrations",
permissions: "Autorisations",
diagnostics: "Diagnostic",
about: "À propos",
},
editor: {
title: "Éditeur",
vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
},
hostSections: {
connections: "Relations",
agents: "Agents",
+52 -9
View File
@@ -470,6 +470,7 @@ export const ja: TranslationResources = {
},
tabs: {
loading: "読み込み中...",
modified: "未保存の変更",
loadingAgentTitle: "エージェントタイトルを読み込み中",
emptyPane: "このペインにタブがありません。",
fallback: {
@@ -537,6 +538,11 @@ export const ja: TranslationResources = {
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
},
confirmations: {
unsavedTitle: "未保存の変更",
unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。",
closeWithoutSaving: "保存せずに閉じる",
closePaneTitle: "ペインを閉じますか?",
bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。",
close: "閉じる",
cancel: "キャンセル",
archive: "アーカイブ",
@@ -584,19 +590,34 @@ export const ja: TranslationResources = {
scripts: {
title: "スクリプト",
actions: {
chooseUrl: "URLを選択",
copyUrl: "URLをコピー",
openService: "サービスを表示",
restart: "再起動",
run: "実行",
view: "表示",
stop: "停止",
view: "ターミナルを表示",
},
accessibility: {
trigger: "ワークスペーススクリプト",
openAt: "{{label}}で{{scriptName}}を開く",
openService: "{{scriptName}}サービスを表示",
viewTerminal: "{{scriptName}}ターミナルを表示",
runScript: "{{scriptName}}スクリプトを実行",
stopScript: "{{scriptName}}を停止",
restartScript: "{{scriptName}}を再起動",
copyUrl: "{{scriptName}}のURLをコピー",
chooseUrl: "{{scriptName}}のURLを選択",
script: "{{scriptName}}スクリプト",
},
routes: {
public: "リバースプロキシ",
paseo: "Memorable",
direct: "直接接続",
},
states: {
exitCode: "終了コード: {{code}}",
startFailed: "{{scriptName}}の起動に失敗しました",
stopFailed: "{{scriptName}}の停止に失敗しました",
},
},
git: {
@@ -778,14 +799,12 @@ export const ja: TranslationResources = {
deletedFile: "削除済み",
commits: {
title: "コミット",
legendLocal: "ローカル",
legendRemote: "リモート",
countLabel: "ベースより先のコミット数: {{count}}",
countLabel: "最近のコミット数: {{count}}",
fileDiffEmpty: "表示する変更はありません",
fileDiffError: "ファイル差分の読み込みに失敗しました",
loading: "コミットを読み込み中…",
loadError: "コミットの読み込みに失敗しました",
empty: "ベースより先のコミットはありません",
empty: "コミットはまだありません",
},
},
openInEditor: {
@@ -918,6 +937,8 @@ export const ja: TranslationResources = {
actions: {
menu: "ワークスペースアクション",
newWorkspace: "新しいワークスペース",
showMore: "さらに表示",
showLess: "表示を減らす",
createWorkspaceFor: "{{projectName}}の新しいワークスペースを作成",
copyPath: "パスをコピー",
copyBranchName: "ブランチ名をコピー",
@@ -975,9 +996,6 @@ export const ja: TranslationResources = {
refPicker: {
startingRef: "開始Ref",
chooseStart: "開始点を選択",
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
intoBase: "{{baseRef}}に",
searching: "検索中...",
noMatchingRefs: "一致するRefがありません。",
@@ -1463,6 +1481,25 @@ export const ja: TranslationResources = {
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
fileSize: "ファイルサイズ {{size}}",
lines: "{{count}} 行",
editorStatus: "エディターの状態 {{status}}",
unsavedChanges: "未保存の変更",
saving: "保存中...",
saveFailed: "保存に失敗しました",
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
unavailableTitle: "ディスク上のファイルを利用できません",
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
overwrite: "上書き",
reload: "再読み込み",
reloadTitle: "ディスクから再読み込みしますか?",
reloadMessage: "ローカルの変更は失われます。",
},
},
diff: {
changesLabel: "変更",
@@ -1558,12 +1595,18 @@ export const ja: TranslationResources = {
sections: {
general: "一般",
appearance: "外観",
editor: "エディター",
shortcuts: "ショートカット",
integrations: "連携",
permissions: "権限",
diagnostics: "診断",
about: "アプリ情報",
},
editor: {
title: "エディター",
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
hostSections: {
connections: "接続",
agents: "エージェント",
+53 -9
View File
@@ -470,6 +470,7 @@ export const ptBR: TranslationResources = {
},
tabs: {
loading: "Carregando...",
modified: "Alterações não salvas",
loadingAgentTitle: "Carregando título do agente",
emptyPane: "Nenhuma aba neste painel.",
fallback: {
@@ -536,6 +537,12 @@ export const ptBR: TranslationResources = {
failedToReloadAgent: "Falha ao recarregar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.",
closeWithoutSaving: "Fechar sem salvar",
closePaneTitle: "Fechar painel?",
bulkUnsaved:
"{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.",
close: "Fechar",
cancel: "Cancelar",
archive: "Arquivar",
@@ -584,19 +591,34 @@ export const ptBR: TranslationResources = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "Escolher URL",
copyUrl: "Copiar URL",
openService: "Ver serviço",
restart: "Reiniciar",
run: "Executar",
view: "Ver",
stop: "Parar",
view: "Ver terminal",
},
accessibility: {
trigger: "Scripts do workspace",
openAt: "Abrir {{scriptName}} em {{label}}",
openService: "Ver serviço {{scriptName}}",
viewTerminal: "Ver terminal de {{scriptName}}",
runScript: "Executar script {{scriptName}}",
stopScript: "Parar {{scriptName}}",
restartScript: "Reiniciar {{scriptName}}",
copyUrl: "Copiar URL de {{scriptName}}",
chooseUrl: "Escolher URL para {{scriptName}}",
script: "script {{scriptName}}",
},
routes: {
public: "Proxy reverso",
paseo: "Memorable",
direct: "Direta",
},
states: {
exitCode: "saída {{code}}",
startFailed: "Falha ao iniciar {{scriptName}}",
stopFailed: "Falha ao parar {{scriptName}}",
},
},
git: {
@@ -788,14 +810,12 @@ export const ptBR: TranslationResources = {
deletedFile: "Excluído",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "no remoto",
countLabel: "{{count}} commits à frente da base",
countLabel: "{{count}} commits recentes",
fileDiffEmpty: "Nenhuma alteração para exibir",
fileDiffError: "Falha ao carregar diff do arquivo",
loading: "Carregando commits…",
loadError: "Falha ao carregar commits",
empty: "Nenhum commit à frente da base",
empty: "Ainda não há commits",
},
},
openInEditor: {
@@ -928,6 +948,8 @@ export const ptBR: TranslationResources = {
actions: {
menu: "Ações do workspace",
newWorkspace: "Novo workspace",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
createWorkspaceFor: "Criar um novo workspace para {{projectName}}",
copyPath: "Copiar caminho",
copyBranchName: "Copiar nome da branch",
@@ -985,9 +1007,6 @@ export const ptBR: TranslationResources = {
refPicker: {
startingRef: "Ref inicial",
chooseStart: "Escolha de onde começar",
checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}",
intoBase: "em {{baseRef}}",
searching: "Buscando...",
noMatchingRefs: "Nenhuma ref correspondente.",
@@ -1475,6 +1494,25 @@ export const ptBR: TranslationResources = {
binaryPreviewUnavailable: "Prévia binária indisponível",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
fileSize: "Tamanho {{size}}",
lines: "{{count}} linhas",
editorStatus: "Status do editor: {{status}}",
unsavedChanges: "Alterações não salvas",
saving: "Salvando...",
saveFailed: "Falha ao salvar",
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
unavailableTitle: "Arquivo indisponível no disco",
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
overwrite: "Sobrescrever",
reload: "Recarregar",
reloadTitle: "Recarregar do disco?",
reloadMessage: "Suas alterações locais serão perdidas.",
},
},
diff: {
changesLabel: "Alterações",
@@ -1570,12 +1608,18 @@ export const ptBR: TranslationResources = {
sections: {
general: "Geral",
appearance: "Aparência",
editor: "Editor",
shortcuts: "Atalhos",
integrations: "Integrações",
permissions: "Permissões",
diagnostics: "Diagnósticos",
about: "Sobre",
},
editor: {
title: "Editor",
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
hostSections: {
connections: "Conexões",
agents: "Agentes",
+54 -10
View File
@@ -470,6 +470,7 @@ export const ru: TranslationResources = {
},
tabs: {
loading: "Загрузка...",
modified: "Несохранённые изменения",
loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.",
fallback: {
@@ -536,6 +537,13 @@ export const ru: TranslationResources = {
failedToReloadAgent: "Не удалось перезагрузить агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
unsavedMessage:
"В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.",
closeWithoutSaving: "Закрыть без сохранения",
closePaneTitle: "Закрыть панель?",
bulkUnsaved:
"В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.",
close: "Закрывать",
cancel: "Отмена",
archive: "Архив",
@@ -585,19 +593,34 @@ export const ru: TranslationResources = {
scripts: {
title: "Скрипты",
actions: {
chooseUrl: "Выбрать URL",
copyUrl: "Скопировать URL",
openService: "Просмотреть сервис",
restart: "Перезапустить",
run: "Бегать",
view: "Вид",
stop: "Остановить",
view: "Открыть терминал",
},
accessibility: {
trigger: "Скрипты Workspace",
openAt: "Откройте{{scriptName}}на{{label}}",
openService: "Просмотреть сервис {{scriptName}}",
viewTerminal: "Посмотреть терминал{{scriptName}}",
runScript: "Запустите скрипт{{scriptName}}",
stopScript: "Остановить{{scriptName}}",
restartScript: "Перезапустить{{scriptName}}",
copyUrl: "Скопировать URL {{scriptName}}",
chooseUrl: "Выбрать URL для {{scriptName}}",
script: "скрипт{{scriptName}}",
},
routes: {
public: "Обратный прокси",
paseo: "Memorable",
direct: "Прямой адрес",
},
states: {
exitCode: "выйти из{{code}}",
startFailed: "Не удалось запустить{{scriptName}}",
stopFailed: "Не удалось остановить{{scriptName}}",
},
},
git: {
@@ -787,14 +810,12 @@ export const ru: TranslationResources = {
deletedFile: "Удалено",
commits: {
title: "Коммиты",
legendLocal: "локально",
legendRemote: "на удалённом",
countLabel: "{{count}} коммитов впереди базы",
countLabel: "{{count}} последних коммитов",
fileDiffEmpty: "Нет изменений для отображения",
fileDiffError: "Не удалось загрузить различия файла",
loading: "Загрузка коммитов…",
loadError: "Не удалось загрузить коммиты",
empty: "Нет коммитов впереди базы",
empty: "Коммитов пока нет",
},
},
openInEditor: {
@@ -927,6 +948,8 @@ export const ru: TranslationResources = {
actions: {
menu: "Действия Workspace",
newWorkspace: "Новое рабочее пространство",
showMore: "Показать ещё",
showLess: "Показать меньше",
createWorkspaceFor: "Создайте новое рабочее пространство для{{projectName}}.",
copyPath: "Копировать путь",
copyBranchName: "Скопировать название ветки",
@@ -984,10 +1007,6 @@ export const ru: TranslationResources = {
refPicker: {
startingRef: "Начальная ссылка",
chooseStart: "Выберите, с чего начать",
checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint:
"Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}",
intoBase: "в {{baseRef}}",
searching: "Идет поиск...",
noMatchingRefs: "Нет подходящих ссылок.",
@@ -1480,6 +1499,25 @@ export const ru: TranslationResources = {
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
fileSize: "Размер файла {{size}}",
lines: "Строк: {{count}}",
editorStatus: "Состояние редактора: {{status}}",
unsavedChanges: "Несохранённые изменения",
saving: "Сохранение...",
saveFailed: "Не удалось сохранить",
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Просмотр",
source: "Исходник",
unavailableTitle: "Файл недоступен на диске",
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
overwrite: "Перезаписать",
reload: "Перезагрузить",
reloadTitle: "Перезагрузить с диска?",
reloadMessage: "Локальные изменения будут потеряны.",
},
},
diff: {
changesLabel: "Изменения",
@@ -1575,12 +1613,18 @@ export const ru: TranslationResources = {
sections: {
general: "Общий",
appearance: "Появление",
editor: "Редактор",
shortcuts: "Ярлыки",
integrations: "Интеграции",
permissions: "Разрешения",
diagnostics: "Диагностика",
about: "О",
},
editor: {
title: "Редактор",
vimKeybindings: "Клавиши Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
hostSections: {
connections: "Соединения",
agents: "Agents",
+52 -9
View File
@@ -466,6 +466,7 @@ export const zhCN: TranslationResources = {
},
tabs: {
loading: "正在加载...",
modified: "未保存的更改",
loadingAgentTitle: "正在加载 Agent 标题",
emptyPane: "此窗格中没有标签。",
fallback: {
@@ -532,6 +533,11 @@ export const zhCN: TranslationResources = {
failedToReloadAgent: "重新加载 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。",
closeWithoutSaving: "不保存并关闭",
closePaneTitle: "关闭面板?",
bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。",
close: "关闭",
cancel: "取消",
archive: "归档",
@@ -578,19 +584,34 @@ export const zhCN: TranslationResources = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "选择 URL",
copyUrl: "复制 URL",
openService: "查看服务",
restart: "重启",
run: "运行",
view: "查看",
stop: "停止",
view: "查看终端",
},
accessibility: {
trigger: "Workspace scripts",
openAt: "在 {{label}} 打开 {{scriptName}}",
openService: "查看 {{scriptName}} 服务",
viewTerminal: "查看 {{scriptName}} Terminal",
runScript: "运行 {{scriptName}} script",
stopScript: "停止 {{scriptName}}",
restartScript: "重启 {{scriptName}}",
copyUrl: "复制 {{scriptName}} 的 URL",
chooseUrl: "选择 {{scriptName}} 的 URL",
script: "{{scriptName}} script",
},
routes: {
public: "反向代理",
paseo: "Memorable",
direct: "直接地址",
},
states: {
exitCode: "exit {{code}}",
startFailed: "启动 {{scriptName}} 失败",
stopFailed: "停止 {{scriptName}} 失败",
},
},
git: {
@@ -759,14 +780,12 @@ export const zhCN: TranslationResources = {
deletedFile: "已删除",
commits: {
title: "提交",
legendLocal: "本地",
legendRemote: "已推送",
countLabel: "领先基线 {{count}} 个提交",
countLabel: "最近 {{count}} 个提交",
fileDiffEmpty: "没有可显示的更改",
fileDiffError: "加载文件差异失败",
loading: "正在加载提交…",
loadError: "加载提交失败",
empty: "没有领先基线的提交",
empty: "暂无提交",
},
},
openInEditor: {
@@ -897,6 +916,8 @@ export const zhCN: TranslationResources = {
actions: {
menu: "Workspace 操作",
newWorkspace: "新建 workspace",
showMore: "显示更多",
showLess: "收起",
createWorkspaceFor: "为 {{projectName}} 新建 workspace",
copyPath: "复制路径",
copyBranchName: "复制分支名称",
@@ -953,9 +974,6 @@ export const zhCN: TranslationResources = {
refPicker: {
startingRef: "起始 ref",
chooseStart: "选择起始位置",
checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示",
intoBase: "进入 {{baseRef}}",
searching: "正在搜索...",
noMatchingRefs: "没有匹配的 refs。",
@@ -1428,6 +1446,25 @@ export const zhCN: TranslationResources = {
binaryPreviewUnavailable: "二进制预览不可用",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
fileSize: "文件大小 {{size}}",
lines: "{{count}} 行",
editorStatus: "编辑器状态 {{status}}",
unsavedChanges: "未保存的更改",
saving: "正在保存...",
saveFailed: "保存失败",
changedOnDisk: "磁盘上的文件已更改",
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
preview: "预览",
source: "源代码",
unavailableTitle: "磁盘上的文件不可用",
conflictDescription: "本地内容已保留。请选择要保留的版本。",
overwrite: "覆盖",
reload: "重新加载",
reloadTitle: "从磁盘重新加载?",
reloadMessage: "本地更改将丢失。",
},
},
diff: {
changesLabel: "更改",
@@ -1523,12 +1560,18 @@ export const zhCN: TranslationResources = {
sections: {
general: "通用",
appearance: "外观",
editor: "编辑器",
shortcuts: "快捷键",
integrations: "集成",
permissions: "权限",
diagnostics: "诊断",
about: "关于",
},
editor: {
title: "编辑器",
vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。",
},
hostSections: {
connections: "连接",
agents: "Agents",
+2
View File
@@ -5,6 +5,7 @@
* Z-index scale within overlay root:
* - Modal backdrop/content: 10
* - Toast: 20
* - Tooltip: 30
*/
export function getOverlayRoot(): HTMLElement {
let el = document.getElementById("overlay-root");
@@ -22,4 +23,5 @@ export function getOverlayRoot(): HTMLElement {
export const OVERLAY_Z = {
modal: 10,
toast: 20,
tooltip: 30,
} as const;
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
@@ -7,115 +7,37 @@ import {
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
} from "./host-runtime-bootstrap";
function createFakeStore() {
return { boot: vi.fn() };
}
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
import type {
DaemonStartCondition,
StartDaemonIfEnabledInput,
} from "@/runtime/daemon-start-service";
describe("startHostRuntimeBootstrap", () => {
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
it("boots the host registry and starts the managed-daemon decision as one operation", () => {
const events: string[] = [];
const shouldStartDaemon = async () => true;
const store = {
boot: vi.fn(() => {
boot: () => {
events.push("boot");
}),
};
const daemonStartService = {
start: vi.fn(async () => {
events.push("daemon-start");
return { ok: true as const };
}),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
expect(events).toEqual(["boot", "daemon-start"]);
});
it("skips daemon-start when shouldStartDaemon is false", () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: false,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("skips daemon-start when the startup gate resolves false", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => false,
});
await Promise.resolve();
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
const onGateError = vi.fn();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => {
throw new Error("settings file unreadable");
},
onGateError,
});
await vi.waitFor(() => {
expect(onGateError).toHaveBeenCalledTimes(1);
});
expect(daemonStartService.start).not.toHaveBeenCalled();
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;
};
let receivedCondition: DaemonStartCondition | null = null;
const daemonStartService = {
start: vi.fn(
() =>
new Promise<{ ok: true }>((resolve) => {
resolveStart = resolve;
}),
),
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
receivedCondition = input.shouldStart;
events.push("daemon-start-decision");
return { ok: true as const };
},
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
shouldStartDaemon,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
resolveStart?.({ ok: true });
expect(events).toEqual(["boot", "daemon-start-decision"]);
expect(receivedCondition).toBe(shouldStartDaemon);
});
});
@@ -1,5 +1,9 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type {
DaemonStartCondition,
DaemonStartResult,
StartDaemonIfEnabledInput,
} from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import {
buildHostRootRoute,
@@ -12,54 +16,22 @@ export interface HostRuntimeBootstrapStore {
}
export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
startIfEnabled: (input: StartDaemonIfEnabledInput) => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
shouldStartDaemon: DaemonStartCondition;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
void input.daemonStartService.startIfEnabled({
shouldStart: input.shouldStartDaemon,
});
}
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}): void {
const gate = input.shouldStartDaemon;
if (typeof gate === "boolean") {
if (gate) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve()
.then(() => gate())
.then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return null;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
});
}
const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker =
+17 -10
View File
@@ -11,7 +11,7 @@ import React, {
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Text, View } from "react-native";
import { ActivityIndicator, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -326,6 +326,7 @@ function useAgentPanelDescriptor(
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: descriptorState.status
@@ -1023,7 +1024,7 @@ function ChatAgentContent({
]);
const animatedContentStyle = useMemo(
() => [styles.content, animatedKeyboardStyle],
() => [animatedStaticStyles.content, animatedKeyboardStyle],
[animatedKeyboardStyle],
);
@@ -1467,7 +1468,11 @@ function ActiveAgentComposer({
});
const inputAreaStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
() => [
animatedStaticStyles.inputAreaWrapper,
{ paddingBottom: insets.bottom },
composerKeyboardStyle,
],
[insets.bottom, composerKeyboardStyle],
);
@@ -1592,6 +1597,15 @@ const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const animatedStaticStyles = RNStyleSheet.create({
content: {
flex: 1,
},
inputAreaWrapper: {
width: "100%",
},
});
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
@@ -1606,13 +1620,6 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
...(isWeb ? { userSelect: "none" as const } : {}),
},
content: {
flex: 1,
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
historySyncOverlay: {
position: "absolute",
top: 0,
+3 -1
View File
@@ -42,10 +42,12 @@ function useBrowserPanelDescriptor(target: {
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
const url = browser?.url ?? "https://example.com";
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
const label = getBrowserLabel({ title: browser?.title ?? "", url });
return {
label: getBrowserLabel({ title: browser?.title ?? "", url }),
label,
subtitle: url,
tooltip: url || label,
titleState: "ready",
icon,
statusBucket: browser?.isLoading ? "running" : null,
@@ -132,6 +132,7 @@ function useCommitDiffPanelDescriptor(
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
@@ -14,6 +14,7 @@ export function buildDraftPanelDescriptor(input: {
return {
label: creatingLabel,
subtitle: i18n.t("panels.draft.creatingAgent"),
tooltip: creatingLabel,
titleState: "ready",
icon,
statusBucket: "running",
@@ -23,6 +24,7 @@ export function buildDraftPanelDescriptor(input: {
return {
label: newAgentLabel,
subtitle: newAgentLabel,
tooltip: newAgentLabel,
titleState: "ready",
icon,
statusBucket: null,
+15 -5
View File
@@ -1,11 +1,12 @@
import { Text, View } from "react-native";
import { FileText } from "lucide-react-native";
import { useMemo } from "react";
import invariant from "tiny-invariant";
import { useTranslation } from "react-i18next";
import { FilePane } from "@/components/file-pane";
import { FilePane } from "@/file-pane/pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { createMaterialFileIcon } from "@/components/material-file-icon";
const CENTERED_PADDED_STYLE = {
flex: 1,
@@ -16,18 +17,20 @@ const CENTERED_PADDED_STYLE = {
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
const fileName = target.path.split("/").findLast(Boolean) ?? target.path;
const icon = useMemo(() => createMaterialFileIcon(fileName), [fileName]);
return {
label: fileName,
subtitle: target.path,
tooltip: target.path,
titleState: "ready" as const,
icon: FileText,
icon,
statusBucket: null,
};
}
function FilePanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext();
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) {
@@ -37,7 +40,14 @@ function FilePanel() {
</View>
);
}
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceDirectory}
location={target}
navigationRevision={fileNavigationRevision ?? 0}
/>
);
}
export const filePanelRegistration: PanelRegistration<"file"> = {
+1
View File
@@ -8,6 +8,7 @@ export interface PaneContextValue {
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
fileNavigationRevision?: number;
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import {
getPanelInstanceAttributes,
setPanelInstanceAttributes,
subscribePanelInstanceAttributes,
} from "./panel-instance-attributes";
describe("panel instance attributes", () => {
test("keeps runtime attributes isolated by workspace and tab", () => {
const first = { serverId: "server", workspaceId: "one", tabId: "tab" };
const second = { serverId: "server", workspaceId: "two", tabId: "tab" };
setPanelInstanceAttributes(first, { modified: true });
expect(getPanelInstanceAttributes(first)).toEqual({ modified: true });
expect(getPanelInstanceAttributes(second)).toEqual({ modified: false });
setPanelInstanceAttributes(first, { modified: false });
});
test("notifies subscribers only when attributes change", () => {
const identity = { serverId: "server", workspaceId: "workspace", tabId: "observed" };
let notifications = 0;
const unsubscribe = subscribePanelInstanceAttributes(identity, () => {
notifications += 1;
});
setPanelInstanceAttributes(identity, { modified: true });
setPanelInstanceAttributes(identity, { modified: true });
setPanelInstanceAttributes(identity, { modified: false });
expect(notifications).toBe(2);
unsubscribe();
});
});
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { usePaneContext } from "@/panels/pane-context";
export interface PanelInstanceIdentity {
serverId: string;
workspaceId: string;
tabId: string;
}
export interface PanelInstanceAttributes {
modified: boolean;
suspendPendingSave?: () => () => void;
}
const DEFAULT_ATTRIBUTES: PanelInstanceAttributes = { modified: false };
const attributesByPanel = new Map<string, PanelInstanceAttributes>();
const listenersByPanel = new Map<string, Set<() => void>>();
const allListeners = new Set<() => void>();
let attributesRevision = 0;
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
}
export function getPanelInstanceAttributes(
identity: PanelInstanceIdentity,
): PanelInstanceAttributes {
return attributesByPanel.get(buildPanelInstanceKey(identity)) ?? DEFAULT_ATTRIBUTES;
}
export function setPanelInstanceAttributes(
identity: PanelInstanceIdentity,
attributes: PanelInstanceAttributes,
): void {
const key = buildPanelInstanceKey(identity);
const previous = attributesByPanel.get(key) ?? DEFAULT_ATTRIBUTES;
if (
previous.modified === attributes.modified &&
previous.suspendPendingSave === attributes.suspendPendingSave
) {
return;
}
if (attributes.modified) attributesByPanel.set(key, attributes);
else attributesByPanel.delete(key);
attributesRevision += 1;
for (const listener of listenersByPanel.get(key) ?? []) listener();
for (const listener of allListeners) listener();
}
export function useModifiedPanelTabIds(input: {
serverId: string;
workspaceId: string;
tabIds: string[];
}): Set<string> {
const revision = useSyncExternalStore(
useCallback((listener: () => void) => {
allListeners.add(listener);
return () => allListeners.delete(listener);
}, []),
() => attributesRevision,
() => attributesRevision,
);
return useMemo(() => {
void revision;
return new Set(
input.tabIds.filter(
(tabId) =>
getPanelInstanceAttributes({
serverId: input.serverId,
workspaceId: input.workspaceId,
tabId,
}).modified,
),
);
}, [input.serverId, input.tabIds, input.workspaceId, revision]);
}
export function subscribePanelInstanceAttributes(
identity: PanelInstanceIdentity,
listener: () => void,
): () => void {
const key = buildPanelInstanceKey(identity);
const listeners = listenersByPanel.get(key) ?? new Set<() => void>();
listeners.add(listener);
listenersByPanel.set(key, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) listenersByPanel.delete(key);
};
}
export function usePanelInstanceAttributes({
serverId,
workspaceId,
tabId,
}: PanelInstanceIdentity): PanelInstanceAttributes {
const subscribe = useCallback(
(listener: () => void) =>
subscribePanelInstanceAttributes({ serverId, workspaceId, tabId }, listener),
[serverId, tabId, workspaceId],
);
const getSnapshot = useCallback(
() => getPanelInstanceAttributes({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function usePublishPanelInstanceAttributes(attributes: PanelInstanceAttributes): void {
const { serverId, workspaceId, tabId } = usePaneContext();
const modified = attributes.modified;
const suspendPendingSave = attributes.suspendPendingSave;
useEffect(() => {
const identity = { serverId, workspaceId, tabId };
setPanelInstanceAttributes(identity, { modified, suspendPendingSave });
return () => setPanelInstanceAttributes(identity, DEFAULT_ATTRIBUTES);
}, [modified, serverId, suspendPendingSave, tabId, workspaceId]);
}
+2 -4
View File
@@ -10,6 +10,7 @@ export interface PanelIconProps {
export interface PanelDescriptor {
label: string;
subtitle: string;
tooltip: string;
titleState: "ready" | "loading";
icon: ComponentType<PanelIconProps>;
statusBucket: SidebarStateBucket | null;
@@ -18,6 +19,7 @@ export interface PanelDescriptor {
export interface PanelDescriptorContext {
serverId: string;
workspaceId: string;
tabId: string;
}
export interface PanelRegistration<
@@ -29,10 +31,6 @@ export interface PanelRegistration<
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext,
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext,
): Promise<boolean>;
}
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
@@ -49,6 +49,7 @@ function useProviderSubagentDescriptor(
return {
label,
subtitle: `${formatProviderLabel(provider)} subagent`,
tooltip: label,
titleState: descriptor ? "ready" : "loading",
icon: getProviderIcon(provider),
statusBucket: descriptor
+3
View File
@@ -37,6 +37,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.completed"),
tooltip: t("workspace.setup.descriptor.completed"),
titleState: "ready",
icon: CheckCircle2,
statusBucket: null,
@@ -47,6 +48,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.failed"),
tooltip: t("workspace.setup.descriptor.failed"),
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
@@ -56,6 +58,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.workspace"),
tooltip: t("workspace.setup.descriptor.workspace"),
titleState: "ready",
icon: SquareTerminal,
statusBucket: snapshot?.status === "running" ? "running" : null,
+5 -3
View File
@@ -62,12 +62,14 @@ function useTerminalPanelDescriptor(
);
const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
const label =
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
t("workspace.tabs.fallback.terminal");
return {
label:
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
t("workspace.tabs.fallback.terminal"),
label,
subtitle: t("workspace.tabs.fallback.terminal"),
tooltip: label,
titleState: "ready",
icon: Terminal,
statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),
@@ -164,6 +164,53 @@ describe("DaemonStartService", () => {
expect(runningSnapshots).toEqual([true, false]);
});
it("stays running while deciding whether the managed daemon should start", async () => {
const fake = createFakeStore();
let resolveCondition: ((value: boolean) => void) | undefined;
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const startPromise = service.startIfEnabled({
shouldStart: () =>
new Promise<boolean>((resolve) => {
resolveCondition = resolve;
}),
});
expect(service.isRunning()).toBe(true);
expect(daemonStartCount).toBe(0);
resolveCondition?.(true);
await startPromise;
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(1);
});
it("finishes without starting the daemon when management is disabled", async () => {
const fake = createFakeStore();
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const result = await service.startIfEnabled({ shouldStart: false });
expect(result).toEqual({ ok: true });
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(0);
});
it("clears the error and notifies subscribers when retry begins", async () => {
const fake = createFakeStore();
const startMock = vi
@@ -188,19 +235,27 @@ describe("DaemonStartService", () => {
expect(service.getLastError()).toBeNull();
});
it("recordError surfaces an external error and notifies subscribers", () => {
it("surfaces settings errors through the daemon startup state", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus(),
});
const notifications = vi.fn();
service.subscribe(notifications);
service.recordError("settings file unreadable");
const result = await service.startIfEnabled({
shouldStart: async () => {
throw new Error("settings file unreadable");
},
});
expect(service.getLastError()).toBe("settings file unreadable");
expect(notifications).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: false,
error: "Failed to evaluate desktop daemon settings: settings file unreadable",
});
expect(service.getLastError()).toBe(
"Failed to evaluate desktop daemon settings: settings file unreadable",
);
expect(service.isRunning()).toBe(false);
});
it("stops notifying after a subscriber unsubscribes", async () => {

Some files were not shown because too many files have changed in this diff Show More