Compare commits

..
Author SHA1 Message Date
Mohamed Boudra a2e8a44848 fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
2026-07-23 20:36:22 +02:00
Mohamed Boudra 81bffbd01a fix(import): harden the desktop import flow 2026-07-20 15:16:03 +02:00
Mohamed Boudra 9519c5ba99 refactor(import): load the import engine as a dependency 2026-07-20 15:05:57 +02:00
Mohamed Boudra 194822b939 fix(server): authorize active workspace config 2026-07-19 07:28:45 +02:00
Mohamed Boudra 2505075beb fix(migrate): reject nested command substitutions 2026-07-19 06:30:11 +02:00
Mohamed Boudra 0c3c61be94 fix(migrate): refresh managed checkout config 2026-07-19 06:09:20 +02:00
Mohamed Boudra 7046b118e9 fix(migrate): preserve adopted workspace config 2026-07-19 05:39:59 +02:00
Mohamed Boudra 3fed2f2cd9 fix(migrate): validate source refs and expansions 2026-07-19 05:02:59 +02:00
Mohamed Boudra 234f0d9b75 test(migrate): cover deleted checkout recreation 2026-07-19 04:28:05 +02:00
Mohamed Boudra 548e15c177 fix(migrate): preserve direct port arithmetic 2026-07-19 03:53:07 +02:00
Mohamed Boudra 6240b2d17c test(server): tolerate delayed Windows handle release 2026-07-19 03:39:32 +02:00
Mohamed Boudra 4e99e4c18c fix(server): release workspace watchers on shutdown 2026-07-19 03:06:48 +02:00
Mohamed Boudra 2f07c5918f test(migrate): compare checkout identities portably 2026-07-19 02:47:23 +02:00
Mohamed Boudra 3e6061b169 fix(client): normalize checkout branch refs 2026-07-19 02:27:46 +02:00
Mohamed Boudra 4ed944db2f fix(migrate): validate live source state 2026-07-19 02:12:29 +02:00
Mohamed Boudra af244d0f02 fix(migrate): share service command semantics 2026-07-19 02:01:39 +02:00
Mohamed Boudra f2124f8cf9 fix(migrate): normalize source references 2026-07-19 01:50:04 +02:00
Mohamed Boudra 152cdcabad fix(migrate): preserve Windows shell semantics 2026-07-19 01:39:15 +02:00
Mohamed Boudra 44da3cdf05 fix(migrate): preserve consistent source state 2026-07-19 01:33:48 +02:00
Mohamed Boudra a8c6c2f4bc fix(migrate): gate automatic source discovery 2026-07-19 01:21:02 +02:00
Mohamed Boudra faccbaae59 fix(migrate): validate workspace connection targets 2026-07-19 01:10:18 +02:00
Mohamed Boudra e61c06706c fix(migrate): preserve source command semantics 2026-07-19 01:03:00 +02:00
Mohamed Boudra c54ddfd07f fix(migrate): preserve checkout and shell semantics 2026-07-19 00:48:41 +02:00
Mohamed Boudra ddaaaa27be fix(desktop): flush migration output before completion 2026-07-19 00:29:50 +02:00
Mohamed Boudra fbd02437dc Merge main into project migration branch 2026-07-19 00:28:33 +02:00
Mohamed Boudra b447a650a7 feat(desktop): migrate existing projects and worktrees 2026-07-19 00:22:17 +02:00
Mohamed Boudra 4d2acfbeb5 fix(server): reject imported port offsets 2026-07-17 18:28:10 +02:00
Mohamed Boudra d6bc201f46 fix(app): refresh import availability previews 2026-07-17 18:10:55 +02:00
Mohamed Boudra 5e75e463ec fix(settings): expose remaining import errors 2026-07-17 17:47:16 +02:00
Mohamed Boudra 35d4ae201a fix(settings): tighten conductor service imports 2026-07-17 17:21:23 +02:00
Mohamed Boudra b93d326fb2 fix(settings): preserve conductor migration context 2026-07-17 17:00:30 +02:00
Mohamed Boudra 9505e43925 fix(settings): report remaining import gaps 2026-07-17 16:42:07 +02:00
Mohamed Boudra 2f0e3451de fix(settings): wait for import preview before setup callout 2026-07-17 16:22:10 +02:00
Mohamed Boudra 28cac5859a fix(settings): keep project imports current and complete 2026-07-17 15:56:04 +02:00
Mohamed Boudra 59d9a25a0d fix(settings): handle remaining import edge cases 2026-07-17 15:32:27 +02:00
Mohamed Boudra 60462337b6 fix(settings): close project import edge cases 2026-07-17 15:11:04 +02:00
Mohamed Boudra f44ef807ef fix(settings): harden conductor imports 2026-07-17 14:48:54 +02:00
Mohamed Boudra f030ddefb1 refactor(settings): make project imports pluggable 2026-07-17 14:42:02 +02:00
Mohamed Boudra f7a6548b68 fix(app): refresh stale config import apply 2026-07-17 11:33:56 +02:00
Mohamed Boudra ab9c9b96a7 fix(server): normalize conductor import paths 2026-07-17 11:31:52 +02:00
Mohamed Boudra 8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
393 changed files with 7427 additions and 17261 deletions
+35
View File
@@ -200,6 +200,41 @@ jobs:
if-no-files-found: ignore
retention-days: 7
import-electron:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build app and server dependencies
run: |
npm run build:app-deps
npm run build:server
- name: Build Desktop main
run: npm run build:main --workspace=@getpaseo/desktop
- name: Run product import behavior
run: npm run test:import-electron --workspace=@getpaseo/desktop
- name: Upload import behavior diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: import-electron-macos
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
app-tests:
runs-on: ubuntu-latest
env:
-60
View File
@@ -1,65 +1,5 @@
# Changelog
## 0.2.0-beta.3 - 2026-07-22
### Added
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
### Improved
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
### Fixed
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
## 0.2.0-beta.2 - 2026-07-22
### Added
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
### Improved
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
### Fixed
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
## 0.2.0-beta.1 - 2026-07-17
### Added
-7
View File
@@ -80,13 +80,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
+13 -1
View File
@@ -91,7 +91,19 @@ it does not depend on the server.
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
code imports from `@getpaseo/client`. Node automation uses
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
selection, and the capability-gated host automation facade shared by the CLI and
published import tools.
### External project import
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
import-package release machinery.
### `packages/app` — Mobile + web client (Expo)
-4
View File
@@ -190,10 +190,6 @@ 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,8 +64,6 @@ 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`.
---
+1 -16
View File
@@ -278,15 +278,6 @@ 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.
@@ -383,14 +374,13 @@ 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 --new-workspace 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 --isolation 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
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -401,11 +391,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:
-14
View File
@@ -73,20 +73,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.
+2 -8
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, dropdown menu and tooltip on native) — opens a new Android window, so
- **`Modal`** (combobox, 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,13 +70,7 @@ 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.
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.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
+1 -3
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 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.
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.
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,8 +44,6 @@ 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>))
```
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.
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.
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
+9
View File
@@ -156,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
ineligible-host UI:
```bash
npm run test:import-electron --workspace=@getpaseo/desktop
```
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
+8 -21
View File
@@ -51,27 +51,17 @@ 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 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
subscribed for a short 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, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
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.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -87,14 +77,11 @@ 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 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.
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.
## Relevant code
+1 -1
View File
@@ -1 +1 @@
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
+5 -8
View File
@@ -5,7 +5,6 @@
nodejs_22,
python3,
makeWrapper,
autoPatchelfHook,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
@@ -60,13 +59,10 @@ 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
@@ -75,9 +71,10 @@ buildNpmPackage rec {
buildPhase = ''
runHook preBuild
# 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.
# 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.
npm rebuild node-pty
# Build all server packages in dependency order (defined in package.json)
@@ -92,7 +89,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/speech worker processes) plus an explicit list of non-JS
# forked terminal-worker-process.js) 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.
+62 -88
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -2531,35 +2531,10 @@
"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.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -2579,33 +2554,22 @@
"@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.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
@@ -11091,19 +11055,6 @@
"@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",
@@ -31775,7 +31726,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
@@ -31940,6 +31890,12 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/srvx": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
@@ -35211,13 +35167,8 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"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",
@@ -35232,7 +35183,6 @@
"@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",
@@ -36236,12 +36186,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/server": "0.2.0-beta.3",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/server": "0.2.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36487,24 +36437,27 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/ws": "^8.5.14",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/import": "0.1.0-beta.2",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -36512,6 +36465,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",
@@ -36522,6 +36476,26 @@
"wait-on": "8.0.5"
}
},
"packages/desktop/node_modules/@getpaseo/import": {
"version": "0.1.0-beta.2",
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"smol-toml": "^1.6.0",
"sql.js": "^1.14.1"
},
"bin": {
"paseo-import": "dist/bin.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@getpaseo/client": ">=0.2.0-beta.2"
}
},
"packages/desktop/node_modules/@types/node": {
"version": "24.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
@@ -36744,7 +36718,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,9 +37614,9 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37872,7 +37846,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37859,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38077,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"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.3",
"@getpaseo/highlight": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@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",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38648,7 +38622,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -132,8 +132,6 @@
"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",
@@ -1,49 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
@@ -1,115 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
+12 -6
View File
@@ -28,20 +28,26 @@ test("commit history shows dates and shares diff layout preferences", async ({
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
_electron as electron,
expect,
test,
type ElectronApplication,
type Page,
} from "@playwright/test";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
test.setTimeout(180_000);
const repoRoot = path.resolve(__dirname, "../../..");
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
let electronApp: ElectronApplication;
test.beforeAll(async () => {
installation = await createConductorInstallation();
});
test.afterAll(async () => {
await electronApp?.close();
rmSync(installation.root, { recursive: true, force: true });
});
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
electronApp = await launchProduct();
const page = await electronApp.firstWindow();
await openIntegrations(page);
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
await openImport(page);
await expect(
page.getByText("Paseo will register valid repositories", { exact: false }),
).toBeVisible();
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
await expect(page.getByTestId("import-output")).toContainText(
`Registered project ${installation.repo}.`,
);
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
await page.getByTestId("import-done").click();
writeFileSync(installation.databasePath, "not a sqlite database");
await openImport(page);
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
await page.getByTestId("import-done").click();
await stopImportHost();
await page.getByRole("button", { name: "General", exact: true }).click();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
const row = page.getByTestId("conductor-import-row");
await expect(row).toContainText("Start the Desktop-managed host before importing.");
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
});
async function launchProduct(): Promise<ElectronApplication> {
const metroPort = requiredEnvironment("E2E_METRO_PORT");
return electron.launch({
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
env: {
...process.env,
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
HOME: installation.home,
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
},
});
}
async function openIntegrations(page: Page): Promise<void> {
const settings = page.getByRole("button", { name: "Settings", exact: true });
await expect(settings).toBeVisible({ timeout: 90_000 });
await settings.click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
}
async function openImport(page: Page): Promise<void> {
const button = page
.getByTestId("conductor-import-row")
.getByRole("button", { name: "Import", exact: true });
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("import-sheet")).toBeVisible();
}
async function createConductorInstallation(): Promise<{
root: string;
home: string;
repo: string;
databasePath: string;
}> {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
const home = path.join(root, "home");
const repo = path.join(root, "repo");
mkdirSync(home, { recursive: true });
execFileSync("git", ["init", "-b", "main", repo]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
writeFileSync(path.join(repo, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repo,
});
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
mkdirSync(databaseDirectory, { recursive: true });
const databasePath = path.join(databaseDirectory, "conductor.db");
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
execFileSync(
process.execPath,
[
"-e",
`const fs = require("node:fs");
const initSqlJs = require("sql.js");
(async () => {
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
const databasePath = process.argv[1];
const repo = process.argv[2];
const database = new SQL.Database(fs.readFileSync(databasePath));
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
database.run("DELETE FROM workspaces");
fs.writeFileSync(databasePath, database.export());
database.close();
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
databasePath,
repo,
],
{ cwd: repoRoot },
);
return { root, home, repo: realpathSync(repo), databasePath };
}
async function stopImportHost(): Promise<void> {
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
process.kill(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch {
return;
}
}
throw new Error(`Import host ${pid} did not stop.`);
}
function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}
+6 -178
View File
@@ -1,4 +1,4 @@
import { unlink, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
-333
View File
@@ -1,333 +0,0 @@
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();
});
});
+3 -71
View File
@@ -328,12 +328,6 @@ 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");
@@ -355,70 +349,6 @@ 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;
}
@@ -779,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_LISTEN:
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
@@ -65,8 +65,6 @@ 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
@@ -103,7 +101,6 @@ declare global {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
__desktopDaemonStartRequested?: boolean;
}
}
@@ -132,7 +129,6 @@ 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 {
@@ -149,7 +145,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
function startDesktopDaemon() {
window.__desktopDaemonStartRequested = true;
if (cfg.hangDaemonStart) {
return new Promise(() => undefined);
}
@@ -161,13 +156,6 @@ 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>;
@@ -224,7 +212,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
if (command === "get_desktop_settings") {
await waitForDesktopSettingsResponse();
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
@@ -290,17 +277,6 @@ 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> {
+1 -61
View File
@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
@@ -59,12 +59,6 @@ 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,
@@ -287,57 +281,3 @@ export async function cloneGithubRepoDefaultBranchOnly(
},
};
}
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
const basePath = path.join(fixtureRoot, "base");
const remotePath = path.join(fixtureRoot, "remote.git");
const checkoutPath = path.join(fixtureRoot, "main-only");
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
await mkdir(basePath);
git(["init", "-b", "main"], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
git(["checkout", "-b", "pr-branch-1"], basePath);
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
git(["add", "pr-1.txt"], basePath);
git(["commit", "-m", "Add PR 1"], basePath);
git(["checkout", "main"], basePath);
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
stdio: ["ignore", "pipe", "pipe"],
});
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
execFileSync(
"git",
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
git(["config", "user.name", "Paseo E2E"], checkoutPath);
git(["config", "commit.gpgsign", "false"], checkoutPath);
return {
pr: {
number: 1,
title: "Use pasted PR as start ref",
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
branch: "pr-branch-1",
localPath: basePath,
},
mainCheckout: {
path: checkoutPath,
cleanup: async () => {},
},
cleanup: async () => {
await rm(fixtureRoot, { recursive: true, force: true });
},
};
}
+1 -20
View File
@@ -1,4 +1,4 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import { expect, 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,13 +290,6 @@ 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 });
@@ -358,18 +351,6 @@ 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 -1
View File
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
-1
View File
@@ -22,7 +22,6 @@ interface SavedSettingsHostInput {
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
+63 -11
View File
@@ -7,10 +7,6 @@ interface SessionMessage {
payload?: unknown;
}
interface TimelineSubscriptionWaitOptions {
timeout?: number;
}
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
if (typeof message !== "string") return null;
try {
@@ -40,16 +36,72 @@ export function observeTimelineSubscriptions(page: Page) {
});
return {
async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
): Promise<void> {
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
const expected = [...new Set(agentIds)].sort();
await expect
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
timeout: options.timeout ?? 15_000,
})
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { 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,14 +17,11 @@ import {
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
pasteGithubPrUrl,
searchAndSelectBranchInPicker,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
@@ -33,11 +30,9 @@ 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";
@@ -196,7 +191,6 @@ 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 });
@@ -218,13 +212,6 @@ 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();
@@ -820,100 +807,6 @@ 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,7 +219,6 @@ 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");
+26 -17
View File
@@ -3,7 +3,10 @@ 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 { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
import {
observeLastAssistantFrames,
observeTimelineSubscriptions,
} from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
@@ -72,40 +75,47 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
}
test.describe("Viewed agent timelines", () => {
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
page,
}) => {
test.setTimeout(90_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.secondAgentId], { timeout: 45_000 });
await commitMessage(
scenario,
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
await scenario.client.sendAgentMessage(
scenario.firstAgentId,
"Committed after the first chat unsubscribed.",
"Stream while focused, then finish while hidden.",
);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
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 }),
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 selectAgent(page, "First viewed chat");
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 stays current during unsubscribe grace", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
test("a hidden retained chat catches up when shown", async ({ 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,
@@ -118,7 +128,6 @@ 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,6 +1,5 @@
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";
@@ -44,49 +43,6 @@ 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,
}) => {
@@ -1,8 +1,4 @@
import {
buildHostAgentDetailRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -35,53 +31,11 @@ import {
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "./helpers/hosts";
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,
@@ -163,43 +117,6 @@ async function expectWorkspaceLocation(
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
agentId: "notification-agent",
};
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: target.serverId,
label: "Notification Host",
});
await expect(
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.evaluate((data) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: { data: { ...data, reason: "finished" } },
cancelable: true,
}),
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
@@ -322,9 +239,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("refresh keeps one continuous splash before restoring the workspace route", async ({
page,
}) => {
test("refresh keeps the user on the same workspace route", async ({ page }) => {
const serverId = getServerId();
const daemonGate = await installDaemonWebSocketGate(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
@@ -339,7 +254,6 @@ test.describe("Workspace navigation regression", () => {
serverId,
manageBuiltInDaemon: true,
hangDaemonStart: true,
desktopSettingsDelayMs: 250,
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
});
await openWorkspaceThroughApp(page, { serverId, workspace });
@@ -347,16 +261,14 @@ 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 waitForDesktopDaemonStartRequest(page);
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
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();
@@ -462,13 +374,12 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceDeckEntryCount(page, 2);
await page.evaluate(
({ agentId, serverId: targetServerId, workspaceId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
workspaceId,
agentId,
reason: "finished",
},
@@ -477,7 +388,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
+9 -78
View File
@@ -44,10 +44,7 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
async function createArchivedMissingWorktree(prefix: string) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
@@ -56,19 +53,11 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
@@ -78,21 +67,11 @@ test.describe("Worktree restore", () => {
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, agents, worktree };
return { agent, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
@@ -262,54 +241,6 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
+2 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
@@ -32,11 +33,6 @@
"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",
@@ -51,7 +47,6 @@
"@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",
+4 -1
View File
@@ -27,7 +27,10 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
testIgnore:
process.env.E2E_DESKTOP_RUNTIME === "1"
? ["**/*.real.spec.ts"]
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
@@ -11,7 +11,6 @@ import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
@@ -190,12 +189,6 @@ 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 = {
@@ -421,7 +414,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
+1 -1
View File
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
+9 -6
View File
@@ -60,13 +60,13 @@ import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -147,10 +147,9 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -338,6 +337,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -376,7 +376,11 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const splashError =
@@ -910,7 +914,6 @@ function AppShell() {
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const handledNavigationRef = useRef<string | null>(null);
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
if (redirectedRef.current) {
return;
}
if (!navigationKey || handledNavigationRef.current === navigationKey) {
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
return;
}
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
if (!serverId || !agentId) {
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
return null;
}
@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
@@ -110,9 +110,7 @@ function HostWorkspaceRouteContent() {
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -149,6 +147,7 @@ function HostWorkspaceRouteContent() {
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
@@ -172,7 +171,6 @@ function HostWorkspaceRouteContent() {
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
@@ -187,16 +185,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
@@ -242,7 +234,6 @@ function WorkspaceDeck({
selection={selection}
activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -255,13 +246,11 @@ function WorkspaceDeckEntry({
selection,
activeSelection,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -293,7 +282,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
recoveryAgentId={isActive ? recoveryAgentId : null}
/>
</RetainedPanel>
);
-11
View File
@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -1,9 +0,0 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}
@@ -1,54 +0,0 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}
@@ -1,10 +0,0 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});
@@ -1,55 +0,0 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});
@@ -1,106 +0,0 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}
@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -6,8 +6,11 @@ import {
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";
@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/** Whether gestures may dismiss the compact bottom sheet. */
dismissible?: boolean;
}
export function AdaptiveModalSheet({
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
dismissible = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
enablePanDownToClose={dismissible}
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
@@ -394,7 +394,6 @@ export function AgentList({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: true,
});
},
[isActionSheetVisible, onAgentSelect],
@@ -9,7 +9,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
interface ArchivedAgentCalloutProps {
serverId: string;
@@ -22,7 +21,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
@@ -34,11 +32,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
} catch (error) {
setUnarchiveError(toErrorMessage(error));
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
@@ -47,23 +44,16 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
{unarchiveError ? (
<Text style={styles.errorText} testID="agent-unarchive-error">
{unarchiveError}
</Text>
) : null}
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>
</View>
@@ -107,16 +97,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: theme.spacing[6],
},
},
calloutStack: {
gap: theme.spacing[2],
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
})) as unknown as Record<string, object>;
@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
+2 -3
View File
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<ChangedFilesPane
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FilesPane
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
@@ -1,185 +0,0 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,5 +1,4 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -11,10 +11,6 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -189,11 +185,7 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
@@ -205,11 +197,7 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -237,14 +225,6 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
+157 -111
View File
@@ -15,26 +15,36 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { SvgXml } from "react-native-svg";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -53,8 +63,6 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -63,8 +71,21 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
testID?: string;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
@@ -83,8 +104,6 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -93,17 +112,10 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -112,10 +124,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: treeRowPaddingLeft(depth) },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
@@ -126,42 +138,23 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<Pressable onPress={handlePress} style={pressableStyle}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
return <TreeChevron expanded={isExpanded} />;
@@ -171,15 +164,40 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
@@ -189,7 +207,6 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -202,10 +219,14 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -215,6 +236,10 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -222,16 +247,12 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -326,14 +347,18 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -395,8 +420,6 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -404,7 +427,6 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -414,9 +436,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -558,14 +577,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -576,7 +589,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -595,7 +607,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -768,6 +779,39 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -807,8 +851,6 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -816,10 +858,7 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -827,7 +866,6 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -838,8 +876,6 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -848,8 +884,6 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1028,6 +1062,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1077,8 +1112,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1098,6 +1134,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
@@ -1,13 +1,6 @@
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 React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import {
ActivityIndicator,
Image as RNImage,
@@ -15,7 +8,7 @@ import {
Text,
View,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -36,14 +29,6 @@ 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[];
@@ -57,12 +42,9 @@ 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;
@@ -208,10 +190,9 @@ function FilePreviewBody({
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { theme } = useUnistyles();
const { t } = useTranslation();
const filePath = location.path;
const isMarkdownFile =
@@ -259,7 +240,7 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection, navigationRevision]);
}, [lineHeight, lineSelection]);
if (isLoading && !preview) {
return (
@@ -382,27 +363,15 @@ 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(
@@ -421,359 +390,61 @@ export function FilePane({
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
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 { 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}
/>
);
}
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
/>
) : null}
{errorMessage ? (
{query.data?.error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Text style={styles.errorText}>{query.data.error}</Text>
</View>
) : null}
<FilePreviewBody
preview={preview}
isLoading={isLoading}
preview={query.data?.file ?? null}
isLoading={query.isFetching}
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,
+13 -90
View File
@@ -341,64 +341,6 @@ 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,
@@ -552,12 +494,6 @@ function SidebarFooter({
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
@@ -565,6 +501,14 @@ 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"
@@ -980,6 +924,9 @@ 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,
@@ -1061,8 +1008,8 @@ const styles = StyleSheet.create((theme) => ({
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
@@ -1073,30 +1020,6 @@ 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,26 +569,8 @@ export function createSharedMarkdownRules(): RenderRules {
{children}
</MarkdownInheritedText>
),
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>
),
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
@@ -1,15 +0,0 @@
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;
}
+4 -23
View File
@@ -1689,29 +1689,10 @@ 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. 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>
),
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
// Android keep the same "\n" they rendered before.
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
@@ -94,8 +94,6 @@ 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,
@@ -1634,12 +1632,6 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
@@ -1792,28 +1784,19 @@ function ProjectBlock({
if (!collapsed) {
if (project.workspaces.length > 0) {
projectChildren = (
<>
<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}
</>
<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}
/>
);
} else if (rowModel.trailingAction.kind === "new_workspace") {
projectChildren = (
@@ -2090,12 +2073,6 @@ 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) => {
@@ -2326,6 +2303,7 @@ 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}
@@ -2353,18 +2331,7 @@ function ProjectModeList({
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedChats.map(renderPinnedChat)}
{canTogglePinnedChats ? (
<SidebarGroupToggleRow
expanded={pinnedChatsExpanded}
onPress={togglePinnedChatsExpanded}
testID="sidebar-pinned-show-more"
/>
) : null}
</>
)}
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
</View>
) : null}
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
@@ -2432,9 +2399,7 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
paddingHorizontal: 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,
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
projectListContainer: {
@@ -47,7 +47,6 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -1,106 +0,0 @@
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,8 +42,6 @@ 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
@@ -95,12 +93,6 @@ 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 = (
@@ -108,9 +100,9 @@ export function SidebarStatusWorkspaceList({
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
{pinnedCollapsed
? null
: pinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
@@ -124,18 +116,10 @@ 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}
@@ -205,89 +189,35 @@ function StatusGroupList({
return (
<>
{groups.map((group) => (
<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}`}
/>
<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>
) : null}
</View>
))}
</>
);
}
@@ -793,8 +723,7 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
pinnedSection: {
@@ -1,15 +0,0 @@
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,7 +55,6 @@ 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,
@@ -76,7 +75,7 @@ import {
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -935,17 +934,11 @@ 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(
@@ -11,7 +11,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
@@ -10,7 +10,6 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
full: 9999,
},
borderWidth: {
1: 1,
@@ -103,41 +102,12 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
it("subtracts segmented control inset from the nested segment radius", () => {
const geometry = createControlGeometry(theme);
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);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
});
});
@@ -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 = "xs" | "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,10 +22,8 @@ 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;
@@ -37,7 +35,6 @@ 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,
};
@@ -50,7 +47,6 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
@@ -62,6 +58,10 @@ 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,6 +121,8 @@ 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,
@@ -128,7 +130,7 @@ export function createControlGeometry(theme: Theme) {
return {
buttonXs: {
minHeight: controlHeights.tight,
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -192,41 +194,31 @@ export function createControlGeometry(theme: Theme) {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: controlHeights.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: 0,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: 0,
},
segmentedSegmentXs: {
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.full,
},
segmentedLabelXs: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
};
}
@@ -11,7 +11,6 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -34,7 +33,6 @@ 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";
@@ -218,16 +216,12 @@ function renderDropdownSurface(input: {
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={
isWeb
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})}
>
{body}
</FloatingSurface>
@@ -502,17 +496,6 @@ 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) {
@@ -630,34 +613,6 @@ 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}
@@ -667,7 +622,27 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
{overlay}
<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>
</Modal>
);
}
@@ -903,11 +878,6 @@ 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.surface0 });
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
export function SegmentedControl<T extends string>({
@@ -53,14 +53,9 @@ export function SegmentedControl<T extends string>({
style,
testID,
}: SegmentedControlProps<T>) {
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 containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = segmentedIconSize[size];
const containerStyle = useMemo(
@@ -166,12 +161,9 @@ const styles = StyleSheet.create((theme) => {
return {
container: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
gap: 2,
},
containerSm: {
...geometry.segmentedContainerSm,
@@ -186,9 +178,6 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -196,13 +185,18 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedSegmentMd,
},
segmentSelected: {
backgroundColor: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -215,9 +209,6 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -225,7 +216,7 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedLabelMd,
},
labelSelected: {
color: theme.colors.surface0,
color: theme.colors.foreground,
},
};
});
+21 -19
View File
@@ -12,7 +12,6 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
@@ -24,12 +23,13 @@ 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,6 +446,7 @@ 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);
@@ -515,22 +516,23 @@ 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 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(),
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>
);
}
@@ -568,7 +570,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
bottom: 0,
left: 0,
zIndex: OVERLAY_Z.tooltip,
zIndex: 1000,
},
content: {
paddingVertical: theme.spacing[1],
+1 -1
View File
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
if (input.attachment.kind === "file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
@@ -9,7 +9,6 @@ import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -50,11 +49,6 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "workspace_file") {
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
@@ -160,11 +159,7 @@ describe("useAgentInputDraft live contract", () => {
configurable: true,
});
useDraftStore.setState({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
@@ -429,39 +424,6 @@ describe("useAgentInputDraft live contract", () => {
});
});
it("attaches to an unmounted legacy draft without losing its input", async () => {
const image: AttachmentMetadata = {
id: "legacy-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/legacy-image",
createdAt: 10,
};
useDraftStore.setState({
drafts: {
"draft:legacy-workspace-file": {
input: { text: "legacy text", images: [image] },
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
});
await useDraftStore.getState().attachWorkspaceFile({
draftKey: "draft:legacy-workspace-file",
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
});
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
text: "legacy text",
attachments: [
{ kind: "image", metadata: image },
createWorkspaceFileAttachment({ path: "src/app.ts" }),
],
});
});
it("clear resets text and attachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
+96 -51
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -9,6 +9,7 @@ import {
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
@@ -21,7 +22,6 @@ import {
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -57,7 +57,6 @@ export interface AgentInputDraft {
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
}
@@ -78,66 +77,67 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}),
[formState.selectedServerId, input.draftKey],
);
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
const attachmentFocusRequestId = useDraftStore(
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
const [text, setText] = useState("");
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
const setAttachments = useCallback((updater: AttachmentUpdater) => {
setAttachmentsState((previousAttachments) => {
if (typeof updater === "function") {
return updater(previousAttachments);
}
store.saveDraftInput({ draftKey, draft: next });
},
[draftKey],
);
const setText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
...current,
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
}));
},
[saveDraft],
);
return updater;
});
}, []);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText("");
setAttachmentsState([]);
setIsHydrated(true);
},
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText("");
setAttachmentsState([]);
setIsHydrated(false);
let cancelled = false;
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setHydratedDraftKey(draftKey);
const draft = await store.hydrateDraftInput({
draftKey,
});
if (cancelled) {
return;
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return;
}
if (draft) {
setText(draft.text);
setAttachmentsState(draft.attachments);
}
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
@@ -145,6 +145,52 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return;
}
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
});
if (!isCurrentGeneration) {
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return;
}
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing !== undefined &&
existing.text === text &&
areAttachmentsEqual({
left: existing.attachments,
right: attachments,
});
if (isSameDraft) {
return;
}
if (!hasDraftContent({ text, attachments })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return;
}
store.saveDraftInput({
draftKey,
draft: {
text,
attachments,
},
});
}, [attachments, draftKey, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
@@ -258,7 +304,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
};
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
@@ -50,7 +50,7 @@ import {
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -665,11 +665,7 @@ export function WorkspaceDraftAgentTab({
});
const inputAreaWrapperStyle = useMemo(
() => [
animatedStaticStyles.inputAreaWrapper,
{ paddingBottom: insets.bottom },
composerKeyboardStyle,
],
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
[insets.bottom, composerKeyboardStyle],
);
@@ -751,7 +747,6 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
@@ -766,7 +761,6 @@ export function WorkspaceDraftAgentTab({
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
@@ -778,12 +772,6 @@ export function WorkspaceDraftAgentTab({
);
}
const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
},
});
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -807,6 +795,10 @@ const styles = StyleSheet.create((theme) => ({
configSection: {
gap: theme.spacing[3],
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
importPillRow: {
width: "100%",
paddingHorizontal: theme.spacing[4],
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "./focused-chat-target";
function layoutWithTarget(
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
): WorkspaceLayout {
return {
root: {
kind: "pane",
pane: {
id: "pane-1",
tabIds: ["focused-tab"],
focusedTabId: "focused-tab",
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
},
},
focusedPaneId: "pane-1",
} as WorkspaceLayout;
}
describe("focused chat target", () => {
it("targets the focused agent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
});
it("targets the focused unsent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
});
it("does not guess when the focused tab is not a chat", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
}),
).toBeNull();
});
});
@@ -1,48 +0,0 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllTabs,
findPaneById,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
export interface FocusedChatTarget {
tabId: string;
draftKey: string;
}
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
}): FocusedChatTarget | null {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tab = collectAllTabs(input.layout.root).find(
(candidate) => candidate.tabId === focusedTabId,
);
if (!tab) {
return null;
}
if (tab.target.kind === "agent") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
};
}
if (tab.target.kind === "draft") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({
serverId: input.serverId,
agentId: tab.tabId,
draftId: tab.target.draftId,
}),
};
}
return null;
}
@@ -48,9 +48,7 @@ interface SearchCall {
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
initialText?: string;
onPullRequestDetected?: () => void;
remote?: string | null;
}
@@ -74,14 +72,6 @@ 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: {
@@ -97,8 +87,6 @@ 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 ?? [],
);
@@ -106,22 +94,18 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client: searchClient,
client,
isConnected: true,
serverId: "server-1",
cwd: workingDirectory,
cwd,
setAttachments,
onPullRequestDetected: input.onPullRequestDetected,
});
return {
text,
setText,
setSearchClient,
setWorkingDirectory,
attachments,
setAttachments,
isResolving: autoAttach.isResolving,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
};
}
@@ -137,20 +121,14 @@ describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const onPullRequestDetected = vi.fn();
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
wrapper: createWrapper(),
});
const { result } = renderHook(() => useHarness(client), { 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();
});
@@ -230,115 +208,4 @@ 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();
});
});
+7 -130
View File
@@ -4,7 +4,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
@@ -27,12 +26,9 @@ 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;
}
@@ -43,24 +39,10 @@ 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,
@@ -70,15 +52,6 @@ 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,
@@ -86,12 +59,11 @@ export function useComposerGithubAutoAttach(
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
}).finally(releaseResolving);
});
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
releaseResolving();
};
}, [
params.text,
@@ -114,84 +86,12 @@ export function useComposerGithubAutoAttach(
return useMemo(
() => ({
isResolving: resolvingRefCounts.size > 0,
markGithubAttachmentRemoved,
}),
[markGithubAttachmentRemoved, resolvingRefCounts.size],
[markGithubAttachmentRemoved],
);
}
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,
@@ -242,28 +142,16 @@ async function attachRef({
return;
}
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
const current = latestRef.current;
if (
!item ||
removedRefKeys.has(key) ||
!isSameLookupTarget(snapshot, current) ||
!isRefStillPresent(ref, current)
) {
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
return;
}
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
return;
}
current.setAttachments((attachments) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
return attachments;
latestRef.current.setAttachments((current) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
return current;
}
return toggleGithubAttachment(attachments, item);
return toggleGithubAttachment(current, item);
});
if (item.kind === "change_request") {
current.onPullRequestAdded?.(item);
}
}
function refsReadyForLookup({
@@ -324,17 +212,6 @@ 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));
}
+5 -105
View File
@@ -3,7 +3,6 @@ import {
Pressable,
Text,
ActivityIndicator,
StyleSheet as RNStyleSheet,
type PressableStateCallbackType,
} from "react-native";
import type { TFunction } from "i18next";
@@ -99,7 +98,6 @@ import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
@@ -120,15 +118,6 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
} from "@/attachments/workspace-file";
import {
resolveWorkspaceFileDrop,
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
type QueuedMessage = QueuedComposerMessage;
@@ -409,18 +398,6 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "workspace_file") {
return (
<WorkspaceFileAttachmentPill
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -733,43 +710,6 @@ function FileAttachmentPill({
);
}
interface WorkspaceFileAttachmentPillProps {
attachment: WorkspaceFileComposerAttachment;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function WorkspaceFileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: WorkspaceFileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const fileName = attachment.path.split("/").pop() ?? attachment.path;
return (
<AttachmentPill
testID="composer-workspace-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -814,7 +754,6 @@ function GithubPickerOption({
interface ComposerProps {
agentId: string;
serverId: string;
workspaceId?: string | null;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
@@ -829,8 +768,6 @@ 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;
@@ -840,14 +777,10 @@ 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. */
autoFocus?: boolean;
/** Changing this value requests focus again while autoFocus remains true. */
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -1039,7 +972,6 @@ function ComposerVoiceModeButton({
export function Composer({
agentId,
serverId,
workspaceId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
@@ -1049,7 +981,6 @@ export function Composer({
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
@@ -1058,12 +989,9 @@ export function Composer({
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
onGithubPrDetected,
onGithubPrAutoAttach,
cwd,
clearDraft,
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -1142,8 +1070,6 @@ export function Composer({
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected,
onPullRequestAdded: onGithubPrAutoAttach,
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
@@ -1259,21 +1185,6 @@ export function Composer({
});
}, []);
const handleWorkspaceFileDropped = useCallback(
(payload: WorkspaceFileDragPayload) => {
if (!workspaceId) {
return;
}
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
if (!attachment) {
return;
}
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
focusInput();
},
[focusInput, serverId, setSelectedAttachments, workspaceId],
);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
@@ -1996,7 +1907,7 @@ export function Composer({
);
const composerContainerStyle = useMemo(
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
() => [styles.container, keyboardAnimatedStyle],
[keyboardAnimatedStyle],
);
const inputAreaContainerStyle = useMemo(
@@ -2038,21 +1949,13 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy =
isProcessing ||
isSubmitLoading ||
isUploadingFile ||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
// 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
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{
onFiles: addImages,
onGenericFiles: handleGenericFilesDropped,
onWorkspaceFile: handleWorkspaceFileDropped,
},
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{ disabled: isSubmitBusy },
);
@@ -2113,7 +2016,7 @@ export function Composer({
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
@@ -2161,14 +2064,11 @@ export function Composer({
);
}
const animatedStaticStyles = RNStyleSheet.create({
const styles = StyleSheet.create((theme: Theme) => ({
container: {
flexDirection: "column",
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
+77 -12
View File
@@ -70,7 +70,6 @@ import {
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
stopRealtimeVoice,
} from "./state";
@@ -497,6 +496,65 @@ 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 {
@@ -858,18 +916,22 @@ 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();
}
@@ -1214,18 +1276,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textInputRef.current?.blur?.();
},
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
isDictationRecording: isDictationActive,
markTranscriptForSend: () => {
sendAfterTranscriptRef.current = true;
},
runKeyboardActionImpl(action, {
textInputRef,
isDictatingRef,
sendAfterTranscriptRef,
confirmDictation,
cancelDictation,
startDictation: startDictationIfAvailable,
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
startDictationIfAvailable,
handleToggleRealtimeVoiceShortcut,
isRealtimeVoiceForCurrentAgent,
voice,
}),
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
@@ -1309,7 +1369,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const {
isRecording: isDictating,
isRecordingActive: isDictationActive,
isProcessing: isDictationProcessing,
partialTranscript: _dictationPartialTranscript,
volume: dictationVolume,
@@ -1330,6 +1389,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enableDuration: true,
});
const isDictatingRef = useRef(isDictating);
useEffect(() => {
isDictatingRef.current = isDictating;
}, [isDictating]);
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
voice,
voiceServerId,
@@ -1356,6 +1420,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
startDictationIfAvailableImpl({
dictationUnavailableMessage,
canStartDictation,
isDictatingRef,
toast,
startDictation,
}),
@@ -4,40 +4,12 @@ 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({
@@ -142,27 +114,6 @@ 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,6 +1,5 @@
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";
@@ -46,18 +45,6 @@ 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;
@@ -89,51 +76,6 @@ 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;
+32 -43
View File
@@ -29,7 +29,6 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
type AgentAttentionReason,
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
@@ -155,35 +154,6 @@ const getLatestPermissionRequest = (
return null;
};
interface AgentAttentionNotificationInput {
notification?: AgentAttentionNotificationPayload;
reason: AgentAttentionReason;
serverId: string;
workspaceId: string | undefined;
agentId: string;
assistantMessage: string | null;
permissionRequest: NotificationPermissionRequest | null;
}
function resolveAgentAttentionNotification(
input: AgentAttentionNotificationInput,
): AgentAttentionNotificationPayload | null {
if (input.notification) {
return input.notification.data.workspaceId ? input.notification : null;
}
if (!input.workspaceId) {
return null;
}
return buildAgentAttentionNotificationPayload({
reason: input.reason,
serverId: input.serverId,
workspaceId: input.workspaceId,
agentId: input.agentId,
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
@@ -417,6 +387,8 @@ 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);
@@ -438,9 +410,11 @@ 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(
@@ -530,20 +504,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const assistantMessage =
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
const notification = resolveAgentAttentionNotification({
notification: params.notification,
reason: params.reason,
serverId,
workspaceId,
agentId: params.agentId,
assistantMessage,
permissionRequest,
});
if (!notification) {
return;
}
const notification =
params.notification ??
buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId,
agentId: params.agentId,
assistantMessage: params.reason === "finished" ? assistantMessage : null,
permissionRequest: params.reason === "permission" ? permissionRequest : null,
});
void sendOsNotification({
title: notification.title,
@@ -554,6 +524,17 @@ 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) {
@@ -1346,5 +1327,13 @@ 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.33.0",
version: "0.32.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.32.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.46",
version: "3.0.44",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.46", "--acp"],
command: ["npx", "-y", "cline@3.0.44", "--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.20",
version: "0.1.19",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.20"],
command: ["npx", "-y", "deepagents-acp@0.1.19"],
},
{
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.35",
version: "0.2.31",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
command: ["npx", "-y", "dimcode@0.2.31", "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.22",
version: "0.4.17",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.177.0",
version: "0.174.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.174.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.21",
version: "0.9.14",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.14", "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.3.0",
version: "1.1.4",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
},
{
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.29",
version: "1.1.27",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.3",
version: "1.0.48",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.20.1",
version: "0.19.11",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -1,69 +0,0 @@
import { useEffect } from "react";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToAgent } from "@/utils/navigate-to-agent";
interface OpenAgentEventPayload {
serverId?: unknown;
agentId?: unknown;
}
export function AgentNavigationListener() {
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
if (!serverId || !agentId) {
return;
}
navigateToAgent({ serverId, agentId });
});
useEffect(() => {
const host = getDesktopHost();
const ready = host?.agentNavigation?.ready;
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
return;
}
let disposed = false;
let unlisten: (() => void) | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const connect = async () => {
let dispose: (() => void) | null = null;
try {
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
if (disposed) {
dispose();
return;
}
unlisten = dispose;
const pending = await ready();
if (!disposed && pending) {
openAgent(pending);
}
} catch {
dispose?.();
if (unlisten === dispose) {
unlisten = null;
}
if (!disposed) {
retryTimer = setTimeout(() => void connect(), 1_000);
}
}
};
void connect();
return () => {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
unlisten?.();
};
}, [openAgent]);
return null;
}
@@ -16,6 +16,8 @@ import {
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
import { conductorImportSource } from "@/desktop/imports/conductor";
import { ExternalImport } from "@/desktop/imports/external-import";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
onUninstall={handleUninstallSkills}
/>
</View>
<ExternalImport source={conductorImportSource} />
</View>
</SettingsSection>
);
@@ -17,7 +17,11 @@ interface DaemonStatusData {
logs: DesktopDaemonLogs;
}
export function useDaemonStatus() {
interface UseDaemonStatusOptions {
refetchOnMount?: boolean | "always";
}
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnMount: options.refetchOnMount ?? "always",
retry: false,
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
@@ -1,8 +1,6 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { getIsElectron } from "@/constants/platform";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
const isElectron = getIsElectron();
const localDaemon = useLocalDaemonServerIdState();
const hosts = useHosts();
const { settings, updateSettings } = useDesktopSettings();
const { data, setStatus, refetch } = useDaemonStatus();
const { enable } = useBuiltInDaemonManagement({
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
refreshStatus: refetch,
});
const isLocalhostConfigured =
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId);
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
const visible = isElectron && data?.status.desktopManaged === false;
const onPress = useCallback(() => {
void (async () => {

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